remotedesk v0.6.0 [extra]
Удалённое управление: SSH-терминал, SFTP-файлер, VNC/RDP в браузере. Нативный Python, без внешних демонов.
| version | date | commit | файлов |
|---|---|---|---|
| 0.6.0 | 2026-10-04 | da453bfbe559 | 29 |
README
# remotedesk
Удалённый доступ к серверам и компьютерам прямо из браузера: SSH-терминал, файлы по SFTP, рабочие столы VNC и RDP. Всё на чистом Python, внешних демонов ставить не нужно.
Тип: Модуль. Категория: `extra`. Зависимостей нет.
## Что умеет
В кабинете есть карточка «Удалённый доступ». Слева — список соединений (можно раскладывать по папкам вида `prod/dc1`, искать и сортировать), в центре — вкладки открытых сессий, как в MobaXterm.
- **SSH-терминал** — полноценная консоль в браузере (xterm.js): ввод, ресайз, копипаст, поиск по буферу.
- **Файлы** — файловый менеджер поверх SFTP, следует за активной SSH-сессией: просмотр каталогов, чтение/правка текстовых файлов, заливка (можно перетаскивать), скачивание, ZIP-архивы каталогов, переименование, права, владелец, удаление (в т.ч. рекурсивное с защитой системных путей).
- **VNC/RDP-десктоп** — рабочий стол в canvas: мышь, клавиатура (включая русскую), колесо, пресеты комбинаций (Ctrl+Alt+Del, Alt+Tab, Win+...), вставка текста, буфер обмена из гостя в браузер, курсор-оверлей.
- **Буфер обмена RDP** — текст туда-обратно через канал CLIPRDR (в гость — кнопкой «Вставить», из гостя — через панель с кнопкой «Скопировать»).
## Соединения и креды
Соединения хранятся в `data/remotedesk/connections.json` — без секретов, только ссылки. Логин при подключении — на выбор: сохранённый кред соединения, кред из общего списка или ручной ввод (секрет живёт только в памяти и стирается при уходе со страницы).
Креды (пароли и приватные ключи) лежат шифрованными: `data/remotedesk/credentials.json.enc` (AES-256-GCM, ключ `CRED_KEY` в `.env` либо автогенерация `data/remotedesk/.credkey` с правами 0600). Наружу отдаются только названия — секреты не покидают процесс.
Первое подключение к новому хосту спрашивает подтверждение: для SSH — отпечаток host-key, для RDP — fingerprint сертификата. Подтверждённый пин сохраняется в параметрах соединения и больше не спрашивается (TOFU).
## Права
- `remotedesk_connect` — подключаться к хостам (терминалы и десктопы)
- `remotedesk_manage` — создавать/править/удалять соединения и креды
- `remotedesk_files` — файловый менеджер
## API
- Соединения: `conn_list`, `conn_add {name, protocol, host, port?, group?, params?, cred_ref?}`, `conn_update {id, patch}`, `conn_delete {id}`, `conn_test {id}` (TCP-пробник, вернёт время отклика)
- Креды: `cred_list`, `cred_add {name, username?, kind?, secret?, note?}`, `cred_update {id, patch}`, `cred_delete {id}` (не даст удалить, пока кред используется)
- Файлы (только SSH-соединения): `files_list`, `files_read`, `files_write`, `files_mkdir`, `files_delete {recursive?}`, `files_rename`, `files_chmod`, `files_chown`, `files_zip`
- Служебные: `auth` (разрешить логин с ручным вводом поверх), `conn_pin_save` (сохранить TOFU-пин), `conn_secret` (только внутри процесса)
## Web
- Соединения/креды: `GET /api/remotedesk/conns`, `POST /api/remotedesk/conn`, `POST /api/remotedesk/conn_delete`, `POST /api/remotedesk/conn_test`, `GET /api/remotedesk/creds`, `POST /api/remotedesk/cred`, `POST /api/remotedesk/cred_delete`
- Файлы (только POST — пути и содержимое не светятся в логах и истории браузера): `POST /api/remotedesk/files_list|files_read|files_write|files_mkdir|files_delete|files_rename|files_chmod|files_chown|files_zip`
- Живые сессии: `WS /ws/remotedesk/ssh`, `/ws/remotedesk/vnc`, `/ws/remotedesk/rdp`
## Настройки (`config.schema.json`)
Обязательных нет. Опциональные: `CONNECTIONS_FILE`, `CREDENTIALS_FILE`, `CREDKEY_FILE`, `CRED_KEY`, `SESSION_TTL_MIN` = `30` (автоотключение простаивающей сессии, минут), `MAX_SESSIONS_PER_USER` = `2`, `FILE_MAX_MB` = `10` (лимит заливки/чтения), `CONNECT_TIMEOUT` = `10`.
Зависимости Python (`requirements.txt`): `paramiko` (SSH/SFTP), `pycryptodome` (VNC-auth, RDP-крипто, шифр кредов), `numpy` опционально (ускорение RDP-декода, без него — тот же результат на чистом Python).
Манифест
{
"name": "remotedesk",
"version": "0.6.0",
"category": "extra",
"description": "Удалённое управление: SSH-терминал, SFTP-файлер, VNC/RDP в браузере. Нативный Python, без внешних демонов.",
"requires": "[]",
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "da453bfbe5595f5519764b3d150c21eb8df0e7cd",
"updated": "2026-10-04 15:37:38 +0300",
"integrity": "sha256:ec8bedf8c37b100eb6a10e07cfea4074b3a8a9a767d3dbe751dd81b4d850535c",
"files": "[29 файлов — см. вкладки ниже]",
"note": "",
"wdesc": "",
"wbody": "",
"_extra": {
"api": "",
"events_emitted": "[]",
"events_subscribed": "[]",
"audit_allow": "",
"shared_routes": "",
"ws_routes": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 5.6 КБ
- 0.3 КБ
- 8.9 КБ
- 6.1 КБ
- 26.3 КБ
- 0.5 КБ
- 3.0 КБ
- 25.3 КБ
- 6.3 КБ
- 14.7 КБ
- 8.7 КБ
- 9.8 КБ
- 9.7 КБ
- rdp/
- 0.0 КБ
- 33.7 КБ
- 12.7 КБ
- 7.6 КБ
- 17.2 КБ
- 2.5 КБ
- 29.2 КБ
- 25.4 КБ
- 12.5 КБ
- rfb/
- 0.0 КБ
- 4.9 КБ
- 9.1 КБ
- 3.5 КБ
- 8.8 КБ
- web/
- 7.7 КБ
- 60.1 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
creds.py 8.9 КБ
"""remotedesk.creds — шифрованное хранилище учётных данных.
Файл data/remotedesk/credentials.json.enc — AES-GCM (pycryptodome).
Ключ: CRED_KEY из .env модуля (base64 32 байта) либо автогенерация
в data/remotedesk/.credkey (0600). Секреты наружу не отдаём:
list() — только метаданные, секрет — через secret() внутри процесса.
Модель: {id, name, username, kind (password|key), note, owners, updated_at}
+ secret (только внутри).
"""
import base64
import json
import os
import re
import time
_VERSION = 1
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def _read_text(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except OSError:
return ""
def _write_text(path, text):
d = os.path.dirname(os.path.abspath(path))
os.makedirs(d, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def resolve_key(config=None, data_dir="", credkey_path=""):
"""Вернуть 32-байтный ключ. Источники: CRED_KEY (.env) или файл .credkey."""
cfg = config or {}
raw = str(cfg.get("CRED_KEY") or "").strip()
if raw:
# base64 (44 символа) либо hex (64) либо сырая строка -> sha256.
try:
b = base64.b64decode(raw, validate=False)
if len(b) == 32:
return b
except Exception:
pass
try:
b = bytes.fromhex(raw)
if len(b) == 32:
return b
except Exception:
pass
if len(raw) >= 16:
import hashlib as _h
return _h.sha256(raw.encode()).digest()
# Файловый ключ (автогенерация один раз).
kp = credkey_path or (os.path.join(data_dir or ".", "remotedesk",
".credkey") if data_dir else "")
if kp:
txt = _read_text(kp).strip() if os.path.isfile(kp) else ""
if txt:
try:
b = base64.b64decode(txt, validate=False)
if len(b) == 32:
return b
except Exception:
pass
import secrets as _s
key = _s.token_bytes(32)
try:
_write_text(kp, base64.b64encode(key).decode())
except OSError:
pass
return key
import secrets as _s
return _s.token_bytes(32)
def _aes():
try:
from Crypto.Cipher import AES as _AES
except ImportError:
raise ValueError("нет пакета pycryptodome "
"(нужен для шифрования кредов remotedesk)")
return _AES
def encrypt_blob(key, plain: bytes) -> str:
import secrets as _s
aes = _aes()
nonce = _s.token_bytes(12)
cipher = aes.new(key, aes.MODE_GCM, nonce=nonce)
ct, tag = cipher.encrypt_and_digest(plain)
return json.dumps({
"v": 1,
"nonce": base64.b64encode(nonce).decode(),
"ct": base64.b64encode(ct).decode(),
"tag": base64.b64encode(tag).decode(),
})
def decrypt_blob(key, blob: str) -> bytes:
aes = _aes()
try:
obj = json.loads(blob)
nonce = base64.b64decode(obj["nonce"])
ct = base64.b64decode(obj["ct"])
tag = base64.b64decode(obj["tag"])
except Exception:
raise ValueError("файл кредов повреждён")
try:
cipher = aes.new(key, aes.MODE_GCM, nonce=nonce)
return cipher.decrypt_and_verify(ct, tag)
except Exception:
raise ValueError("неверный CRED_KEY или файл повреждён")
class CredStore:
def __init__(self, path, key):
self.path = path
self.key = key
def load(self):
"""Все записи WITH секретами (только внутри процесса)."""
if not os.path.isfile(self.path):
return {}
blob = _read_text(self.path)
if not blob.strip():
return {}
raw = decrypt_blob(self.key, blob)
try:
data = json.loads(raw.decode("utf-8"))
except Exception:
raise ValueError("файл кредов повреждён")
creds = data.get("creds") if isinstance(data, dict) else None
return creds if isinstance(creds, dict) else {}
def save(self, creds):
plain = json.dumps({"version": _VERSION, "creds": creds or {}},
ensure_ascii=False).encode("utf-8")
_write_text(self.path, encrypt_blob(self.key, plain))
def public(self, rec):
return {
"id": rec.get("id", ""),
"name": rec.get("name", ""),
"username": rec.get("username", ""),
"kind": rec.get("kind", "password"),
"note": rec.get("note", ""),
"owners": list(rec.get("owners") or []),
"updated_at": rec.get("updated_at", ""),
"has_secret": bool(rec.get("secret")),
}
def list(self):
try:
creds = self.load()
except ValueError:
return []
return [self.public(v) for _, v in sorted(creds.items())]
def validate(self, fields, partial=False):
if not isinstance(fields, dict):
return None, "нужен объект"
norm = {}
if not partial or "name" in fields:
name = str(fields.get("name") or "").strip()
if not name or len(name) > 128:
return None, "нужно name (1-128 символов)"
norm["name"] = name
if "username" in fields or not partial:
username = str(fields.get("username") or "").strip()
if len(username) > 128:
return None, "username: до 128 символов"
norm["username"] = username
if "kind" in fields or not partial:
kind = str(fields.get("kind") or "password").strip().lower()
if kind not in ("password", "key"):
return None, "kind: password|key"
norm["kind"] = kind
if "secret" in fields or not partial:
secret = fields.get("secret")
secret = "" if secret is None else str(secret)
if len(secret) > 8192:
return None, "secret: до 8 КБ"
norm["secret"] = secret
if "note" in fields:
note = str(fields.get("note") or "").strip()
if len(note) > 256:
return None, "note: до 256 символов"
norm["note"] = note
if "owners" in fields:
owners = fields.get("owners")
if owners is None:
norm["owners"] = []
elif isinstance(owners, list) and all(
isinstance(x, str) for x in owners):
norm["owners"] = owners[:64]
else:
return None, "owners: список строк"
return norm, ""
def add(self, fields, actor=""):
norm, err = self.validate(fields)
if err:
raise ValueError(err)
import uuid as _uuid
cid = _uuid.uuid4().hex[:12]
if not _ID_RE.match(cid):
raise ValueError("внутренняя ошибка id")
rec = {
"id": cid,
"owners": [actor] if actor else [],
"created_by": actor or "",
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime()),
}
rec.update(norm)
creds = self.load()
creds[cid] = rec
self.save(creds)
return {"ok": True, "id": cid}
def update(self, cid, patch):
if not cid or not _ID_RE.match(str(cid)):
raise ValueError("плохой id")
norm, err = self.validate(patch or {}, partial=True)
if err:
raise ValueError(err)
creds = self.load()
rec = creds.get(cid)
if not isinstance(rec, dict):
raise ValueError("кред не найден: %s" % cid)
rec.update(norm)
rec["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime())
creds[cid] = rec
self.save(creds)
return {"ok": True}
def delete(self, cid):
if not cid or not _ID_RE.match(str(cid)):
raise ValueError("плохой id")
creds = self.load()
if cid not in creds:
raise ValueError("кред не найден: %s" % cid)
del creds[cid]
self.save(creds)
return {"ok": True}
def get(self, cid):
creds = self.load()
rec = creds.get(cid)
return rec if isinstance(rec, dict) else None
module.py 26.3 КБ
"""remotedesk.Module — соединения, креды, SFTP (API + аудит)."""
import logging
import os
import socket
from core.base_module import BaseModule
logger = logging.getLogger(__name__)
# Пул SFTP-клиентов: (actor, cid) -> {client, at}. Один SSH-коннект на
# (uid, conn) вместо нового на каждую файловую операцию. Секреты НЕ
# храним — только живой client; TTL чистит протухшие. Закрытие — через
# _ssh_pool_drop (ошибка канала) или janitor по TTL.
# Ключ пула включает login-отпечаток (cred_id/username): повторный вызов
# с чужим кредом/ручным секретом НЕ должен получить чужое соединение.
import threading as _th
_SSH_POOL = {}
_SSH_POOL_LOCK = _th.Lock()
_SSH_POOL_TTL = 300
_SSH_POOL_MAX_LIFE = 1800
def _login_fp(login):
"""Отпечаток login-оверрайда для ключа пула (без секрета)."""
if not isinstance(login, dict) or not login:
return ""
parts = []
for k in ("cred_id", "username"):
v = login.get(k)
if v is not None and str(v) != "":
parts.append("%s=%s" % (k, str(v)[:256]))
return "|".join(parts)
def _pool_key(actor, cid, login):
return (actor, cid, _login_fp(login))
def _ssh_pool_get(actor, cid, login=None, pin=""):
import time as _t
key = _pool_key(actor, cid, login)
with _SSH_POOL_LOCK:
rec = _SSH_POOL.get(key)
if not rec:
return None
now = _t.time()
if (now - rec.get("at", 0) > _SSH_POOL_TTL
or now - rec.get("born", 0) > _SSH_POOL_MAX_LIFE
or rec.get("pin", "") != (pin or "")):
rec = _SSH_POOL.pop(key, None)
else:
try:
tr = rec["client"].get_transport()
alive = tr is not None and tr.is_active()
except Exception: # noqa: BLE001
alive = False
if not alive:
rec = _SSH_POOL.pop(key, None)
else:
rec["at"] = now
return rec["client"]
if rec is not None:
try:
rec["client"].close()
except Exception: # noqa: BLE001
pass
return None
def _ssh_pool_put(actor, cid, client, login=None, pin=""):
import time as _t
key = _pool_key(actor, cid, login)
with _SSH_POOL_LOCK:
old = _SSH_POOL.pop(key, None)
_SSH_POOL[key] = {"client": client, "at": _t.time(),
"born": _t.time(), "pin": pin or ""}
if old is not None:
try:
old["client"].close()
except Exception: # noqa: BLE001
pass
def _ssh_pool_touch(actor, cid, login=None, ok=True):
import time as _t
key = _pool_key(actor, cid, login)
with _SSH_POOL_LOCK:
rec = _SSH_POOL.get(key)
# Touch только на успехе: умирающий клиент не продлеваем.
if rec and ok:
rec["at"] = _t.time()
def _ssh_pool_drop(actor, cid, login=None):
"""Выкинуть клиент из пула. Вернуть True если был."""
key = _pool_key(actor, cid, login)
with _SSH_POOL_LOCK:
rec = _SSH_POOL.pop(key, None)
if rec is None:
return False
try:
rec["client"].close()
except Exception: # noqa: BLE001
pass
return True
def _ssh_pool_janitor():
import time as _t
now = _t.time()
dead = []
with _SSH_POOL_LOCK:
for key, r in list(_SSH_POOL.items()):
if (now - r.get("at", 0) > _SSH_POOL_TTL
or now - r.get("born", 0) > _SSH_POOL_MAX_LIFE):
dead.append(key)
recs = [_SSH_POOL.pop(k, None) for k in dead]
for rec in recs:
if rec is None:
continue
try:
rec["client"].close()
except Exception: # noqa: BLE001
pass
class Module(BaseModule):
def setup(self, ctx) -> None:
from . import store as _store
from . import creds as _creds
mod_dir = os.path.dirname(os.path.abspath(__file__))
self.config = ctx.module_config("remotedesk", mod_dir)
self.ctx = ctx
def _f(key, default):
return (self.config.get(key, "") or "").strip() or default
from core.paths import abs_data
self.conn_file = abs_data(
ctx.data_dir, _f("CONNECTIONS_FILE", ""),
os.path.join(ctx.data_dir, "remotedesk", "connections.json"))
self.creds_file = abs_data(
ctx.data_dir, _f("CREDENTIALS_FILE", ""),
os.path.join(ctx.data_dir, "remotedesk",
"credentials.json.enc"))
self.credkey_file = abs_data(
ctx.data_dir, _f("CREDKEY_FILE", ""),
os.path.join(ctx.data_dir, "remotedesk", ".credkey"))
try:
self.timeout = max(2, min(60, int(
_f("CONNECT_TIMEOUT", "10") or 10)))
except (TypeError, ValueError):
self.timeout = 10
self.store = _store.Store(self.conn_file)
try:
_key = _creds.resolve_key(self.config, ctx.data_dir,
self.credkey_file)
except ValueError:
_key = None
self._cred_key = _key
self.credstore = _creds.CredStore(self.creds_file, _key) \
if _key else None
api = ctx.api.register
api("remotedesk", "conn_list", self.api_list)
api("remotedesk", "conn_add", self.api_add)
api("remotedesk", "conn_update", self.api_update)
api("remotedesk", "conn_delete", self.api_delete)
api("remotedesk", "conn_test", self.api_test)
api("remotedesk", "conn_secret", self.api_secret)
api("remotedesk", "auth", self.api_auth)
api("remotedesk", "cred_list", self.api_cred_list)
api("remotedesk", "cred_add", self.api_cred_add)
api("remotedesk", "cred_update", self.api_cred_update)
api("remotedesk", "cred_delete", self.api_cred_delete)
api("remotedesk", "conn_pin_save", self.api_pin_save)
api("remotedesk", "files_list", self.api_files_list)
api("remotedesk", "files_read", self.api_files_read)
api("remotedesk", "files_write", self.api_files_write)
api("remotedesk", "files_mkdir", self.api_files_mkdir)
api("remotedesk", "files_delete", self.api_files_delete)
api("remotedesk", "files_rename", self.api_files_rename)
api("remotedesk", "files_chmod", self.api_files_chmod)
api("remotedesk", "files_chown", self.api_files_chown)
api("remotedesk", "files_zip", self.api_files_zip)
def _audit(self, action, target, actor="", detail="", ok=True):
try:
self.ctx.api.call("audit.record", actor or "?", action,
target, detail, ok)
except Exception: # noqa: BLE001
pass
def api_list(self):
return {"ok": True,
"connections": self.store.list()}
def api_add(self, fields, actor=""):
try:
out = self.store.add(fields or {}, actor or "")
except ValueError as e:
self._audit_rec("remotedesk.conn_add", "", actor, str(e), False)
raise ValueError(str(e))
self._audit_rec("remotedesk.conn_add", out["id"], actor)
return out
def api_update(self, cid, patch, actor=""):
try:
out = self.store.update(cid, patch or {})
except ValueError as e:
self._audit_rec("remotedesk.conn_update", cid or "",
actor, str(e), False)
raise ValueError(str(e))
self._audit_rec("remotedesk.conn_update", cid, actor)
return out
def api_delete(self, cid, actor=""):
try:
out = self.store.delete(cid)
except ValueError as e:
self._audit_rec("remotedesk.conn_delete", cid or "",
actor, str(e), False)
raise ValueError(str(e))
self._audit_rec("remotedesk.conn_delete", cid, actor)
return out
def api_test(self, cid):
"""TCP-пробник host:port (без секретов, только reachability)."""
conns = self.store.load()
rec = conns.get(cid)
if not isinstance(rec, dict):
raise ValueError("соединение не найдено: %s" % cid)
host = rec.get("host", "")
port = int(rec.get("port") or 0)
import time as _time
t0 = _time.time()
try:
with socket.create_connection((host, port),
timeout=self.timeout):
pass
except Exception as e: # noqa: BLE001
return {"ok": False,
"ms": int((_time.time() - t0) * 1000),
"error": str(e)[:200]}
return {"ok": True, "ms": int((_time.time() - t0) * 1000)}
def _audit_rec(self, action, target, actor="", detail="", ok=True):
self._audit(action, target, actor, detail, ok)
def api_secret(self, cid):
"""Секрет соединения: cred_ref (шифр. хранилище) -> auth_ref (.env).
Только внутри процесса (WS/SFTP). Наружу не отдавать.
"""
rec = self.store.load().get(cid)
if not isinstance(rec, dict):
raise ValueError("соединение не найдено: %s" % cid)
# 1) привязанный кред из шифрованного хранилища.
cred_ref = (rec.get("cred_ref") or "").strip()
if cred_ref and self.credstore is not None:
try:
cred = self.credstore.get(cred_ref)
except ValueError:
cred = None
if isinstance(cred, dict) and cred.get("secret"):
return cred.get("secret") or ""
# 2) legacy: ключ секрета в .env модуля.
ref = (rec.get("auth_ref") or "").strip()
if not ref:
return ""
try:
val = self.config.get(ref, "") if self.config else ""
except Exception: # noqa: BLE001
val = ""
return val or ""
def api_login(self, cid):
"""(username, secret): дефолтный логин соединения.
username — из params.username, секрет — cred_ref/auth_ref.
Ручной ввод поверх — на уровне WS (login override).
"""
rec = self.store.load().get(cid)
if not isinstance(rec, dict):
raise ValueError("соединение не найдено: %s" % cid)
params = rec.get("params") or {}
user = params.get("username", "") or params.get("user", "")
cred_ref = (rec.get("cred_ref") or "").strip()
if cred_ref and self.credstore is not None:
try:
cred = self.credstore.get(cred_ref)
except ValueError:
cred = None
if isinstance(cred, dict):
if not user:
user = cred.get("username", "")
if cred.get("secret"):
return user, cred.get("secret") or ""
return user, self.api_secret(cid)
def api_auth(self, cid, login=None):
"""Разрешить логин соединения с оверрайдом из веба.
login: {cred_id?, username?, secret?/password?} — ручной ввод
поверх сохранённого. Возвращает (rec, username, secret).
Секреты — только внутри процесса, не логировать.
"""
rec = self.store.load().get(cid)
if not isinstance(rec, dict):
raise ValueError("соединение не найдено: %s" % cid)
user, secret = self.api_login(cid)
login = login if isinstance(login, dict) else {}
# Явный кред из списка (вместо привязанного к соединению).
cred_id = str(login.get("cred_id") or "").strip()
if cred_id and self.credstore is not None:
try:
cred = self.credstore.get(cred_id)
except ValueError:
cred = None
if not isinstance(cred, dict):
raise ValueError("кред не найден: %s" % cred_id)
if str(login.get("username") or "").strip():
user = str(login.get("username") or "").strip()
elif cred.get("username"):
user = cred.get("username")
if cred.get("secret"):
secret = cred.get("secret")
# Ручной ввод — высший приоритет.
if str(login.get("username") or "").strip() and not cred_id:
user = str(login.get("username") or "").strip()
manual = login.get("secret", login.get("password", None))
if manual is not None and str(manual) != "":
secret = str(manual)
if len(secret) > 8192:
raise ValueError("секрет: до 8 КБ")
return rec, user, secret or ""
def api_pin_save(self, cid, field, pin, actor=""):
"""Сохранить TOFU-пин (host_key_pin / cert_pin) в params соединения.
Чтобы подтверждение host-key/сертификата спрашивалось один раз,
а SFTP (он ходит тем же пином) не падал следом.
"""
if field not in ("host_key_pin", "cert_pin"):
raise ValueError("плохое поле пина")
pin = (pin or "").strip()
if not pin or len(pin) > 256:
raise ValueError("плохой пин")
conns = self.store.load()
rec = conns.get(cid)
if not isinstance(rec, dict):
raise ValueError("соединение не найдено: %s" % cid)
params = dict(rec.get("params") or {})
if params.get(field) == pin:
return {"ok": True}
params[field] = pin
self.store.update(cid, {"params": params})
self._audit_rec("remotedesk.pin_save", cid, actor, field)
return {"ok": True}
# --- креды (шифрованное хранилище) ---
def _creds_ok(self):
if self.credstore is None:
raise ValueError("хранилище кредов недоступно "
"(нет pycryptodome)")
return self.credstore
def api_cred_list(self):
return {"ok": True,
"creds": self._creds_ok().list()}
def api_cred_add(self, fields, actor=""):
try:
out = self._creds_ok().add(fields or {}, actor or "")
except ValueError as e:
self._audit_rec("remotedesk.cred_add", "", actor, str(e),
False)
raise ValueError(str(e))
self._audit_rec("remotedesk.cred_add", out["id"], actor)
return out
def api_cred_update(self, cid, patch, actor=""):
try:
out = self._creds_ok().update(cid, patch or {})
except ValueError as e:
self._audit_rec("remotedesk.cred_update", cid or "",
actor, str(e), False)
raise ValueError(str(e))
self._audit_rec("remotedesk.cred_update", cid, actor)
return out
def api_cred_delete(self, cid, actor=""):
# Не даём удалить кред, пока на него ссылаются соединения.
try:
conns = self.store.load()
used = sorted(
c.get("name") or cid2 for cid2, c in conns.items()
if isinstance(c, dict) and
(c.get("cred_ref") or "").strip() == cid)
if used:
raise ValueError("кред используется: %s" % ", ".join(used))
out = self._creds_ok().delete(cid)
except ValueError as e:
self._audit_rec("remotedesk.cred_delete", cid or "",
actor, str(e), False)
raise ValueError(str(e))
self._audit_rec("remotedesk.cred_delete", cid, actor)
return out
def _resolve_ssh(self, cid, actor="", login=None, reuse=False):
"""(rec, client) для SFTP-операции.
reuse=False (дефолт) — свежий клиент, закрыть вызывающему
(как раньше). reuse=True — клиент из пула, НЕ закрывать:
один SSH/SFTP-коннект на (uid, conn).
"""
from . import ssh_client as _ssh
try:
rec, user, secret = self.api_auth(cid, login)
except ValueError:
raise
if rec.get("protocol") != "ssh":
raise ValueError("файлы — только ssh-соединения")
owners = rec.get("owners") or []
if owners and (actor or "") not in owners:
self._audit_rec("remotedesk.files_denied", cid, actor, "", False)
raise ValueError("нет доступа (owners)")
params = dict(rec.get("params") or {})
if not (params.get("host_key_pin") or "").strip():
raise ValueError("нет host-key пина: сначала подключитесь "
"терминалом и подтвердите ключ")
try:
max_mb = float(self.config.get("FILE_MAX_MB", "10") or 10)
except (TypeError, ValueError):
max_mb = 10
max_bytes = max(1, min(100, int(max_mb))) * 1048576
_ssh_pool_janitor()
pin = (params.get("host_key_pin") or "").strip()
if reuse:
pooled = _ssh_pool_get(actor, cid, login, pin)
if pooled is not None:
return rec, pooled, max_bytes
client = _ssh.connect(rec.get("host", ""),
int(rec.get("port") or 22),
user, secret, params, self.timeout)
if reuse:
_ssh_pool_put(actor, cid, client, login, pin)
return rec, client, max_bytes
def _sftp_call(self, cid, actor, login, fn_name, *args):
"""SFTP-операция через пул (клиент НЕ закрываем).
При смерти транспорта — drop + один ретрай свежим коннектом.
Смерть определяем состоянием транспорта (is_active), а не
префиксом строки (транспортные ошибки середины операции
завёрнуты как 'sftp list:/stat:/...' и по строке не ловятся).
Возвращает (out, rec).
"""
from . import ssh_client as _ssh
fn = getattr(_ssh, fn_name)
rec, client, max_bytes = self._resolve_ssh(cid, actor, login,
reuse=True)
ok = False
try:
try:
out = self._call_sftp_fn(fn, client, max_bytes, *args)
except TypeError:
out = fn(client, *args)
ok = True
return out, rec
except ValueError as e:
if self._transport_dead(client) and _ssh_pool_drop(
actor, cid, login):
rec2, client2, mb2 = self._resolve_ssh(cid, actor, login,
reuse=True)
try:
try:
out2 = self._call_sftp_fn(fn, client2, mb2,
*args)
except TypeError:
out2 = fn(client2, *args)
ok = True
return out2, rec2
except ValueError as e2:
raise ValueError(str(e2))
raise ValueError(str(e))
finally:
_ssh_pool_touch(actor, cid, login, ok=ok)
@staticmethod
def _transport_dead(client):
try:
tr = client.get_transport() if client is not None else None
return tr is None or not tr.is_active()
except Exception: # noqa: BLE001
return True
@staticmethod
def _call_sftp_fn(fn, client, max_bytes, *args):
import inspect as _insp
try:
params = _insp.signature(fn).parameters
except (TypeError, ValueError):
params = {}
if "max_bytes" in params:
return fn(client, *args, max_bytes=max_bytes)
return fn(client, *args)
def api_files_list(self, cid, path="", actor="", login=None):
try:
out, rec = self._sftp_call(cid, actor, login, "sftp_list",
path or ".")
except ValueError as e:
raise ValueError(str(e))
self._audit_rec("remotedesk.files_list", "%s:%s" % (cid, path),
actor)
return {"ok": True, "conn": rec.get("name", cid), **out}
def api_files_read(self, cid, path, actor="", login=None):
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_read", path)
except ValueError as e:
self._audit_rec("remotedesk.files_read", "%s:%s" % (
cid, path), actor, str(e)[:200], False)
raise ValueError(str(e))
self._audit_rec("remotedesk.files_read", "%s:%s" % (cid, path),
actor)
return {"ok": True, **out}
def api_files_write(self, cid, path, text="", actor="", login=None,
binary=False):
if not isinstance(text, (str, bytes)):
raise ValueError("плохой text: нужен str/bytes")
if binary:
# Бинарная заливка: фронт шлёт base64 строкой.
import base64 as _b64
try:
raw_in = text.encode() if isinstance(text, str) else text
payload = _b64.b64decode(raw_in, validate=True)
except Exception: # noqa: BLE001
raise ValueError("плохой base64")
else:
if not isinstance(text, str):
raise ValueError("плохой text: нужен str")
payload = text
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_write",
path, payload)
except ValueError as e:
self._audit_rec("remotedesk.files_write", path or "",
actor, str(e)[:200], False)
raise ValueError(str(e))
self._audit_rec("remotedesk.files_write", "%s:%s" % (cid, path),
actor)
return out
def api_files_mkdir(self, cid, path, actor="", login=None):
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_mkdir", path)
except ValueError as e:
raise ValueError(str(e))
self._audit_rec("remotedesk.files_mkdir", "%s:%s" % (cid, path),
actor)
return out
def api_files_delete(self, cid, path, recursive=False, actor="",
login=None):
try:
if recursive:
out, _ = self._sftp_call(cid, actor, login, "sftp_rmtree",
path)
else:
out, _ = self._sftp_call(cid, actor, login,
"sftp_delete", path)
except ValueError as e:
self._audit_rec("remotedesk.files_delete", path or "",
actor, str(e)[:200], False)
raise ValueError(str(e))
self._audit_rec("remotedesk.files_delete", "%s:%s" % (cid, path),
actor)
return out
def api_files_rename(self, cid, src, dst, actor="", login=None):
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_rename",
src, dst)
except ValueError as e:
self._audit_rec("remotedesk.files_rename", src or "",
actor, str(e)[:200], False)
raise ValueError(str(e))
self._audit_rec("remotedesk.files_rename", "%s:%s->%s" % (
cid, src, dst), actor)
return out
def api_files_chmod(self, cid, path, mode, actor="", login=None):
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_chmod",
path, mode)
except ValueError as e:
self._audit_rec("remotedesk.files_chmod", path or "",
actor, str(e)[:200], False)
raise ValueError(str(e))
self._audit_rec("remotedesk.files_chmod", "%s:%s=%s" % (
cid, path, mode), actor)
return out
def api_files_chown(self, cid, path, uid=None, gid=None, actor="",
login=None):
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_chown",
path, uid, gid)
except ValueError as e:
self._audit_rec("remotedesk.files_chown", path or "",
actor, str(e)[:200], False)
raise ValueError(str(e))
self._audit_rec("remotedesk.files_chown", "%s:%s=%s/%s" % (
cid, path, uid, gid), actor)
return out
def api_files_zip(self, cid, path, actor="", login=None):
import base64 as _b64
try:
out, _ = self._sftp_call(cid, actor, login, "sftp_zip_tree",
path)
except ValueError as e:
self._audit_rec("remotedesk.files_zip", "%s:%s" % (
cid, path), actor, str(e)[:200], False)
raise ValueError(str(e))
data = out.pop("data", b"")
self._audit_rec("remotedesk.files_zip", "%s:%s (%d Б)" % (
cid, path, len(data)), actor)
return {"ok": True,
"text": _b64.b64encode(data).decode(),
"binary": True, **out}
def health(self) -> dict:
try:
n = len(self.store.load())
except Exception: # noqa: BLE001
n = -1
try:
from . import session as _sess
sess = _sess.stats()
except Exception: # noqa: BLE001
sess = {}
return {"ok": True, "module": self.name, "connections": n,
**sess}
rdp/__init__.py 0.0 КБ
"""remotedesk.rdp package."""
rdp/client.py 33.7 КБ
"""remotedesk.rdp.client — высокоуровневый RDP-клиент (фазы MS-RDPBCGR).
Фазы: negotiate (tpkt) -> [tls|credssp] -> MCS Connect -> channel join ->
Security Exchange (legacy rdp) -> Capabilities -> Bitmap loop.
Кадр — общий Frame из rfb.frame (переиспользуем PNG-патчи).
"""
import logging
import struct
import threading
import time
logger = logging.getLogger(__name__)
class RdpSession:
def __init__(self, conn, info, frame, user_id, io_channel,
bpp=32, selected="rdp", clip_channel=None):
self.conn = conn
self.info = info
self.frame = frame
self.user_id = user_id
self.io_channel = io_channel
self.clip_channel = clip_channel # id канала cliprdr или None
self.bpp = bpp
self.selected = selected
self.palette = None # палитра 8bit от сервера (список r,g,b)
self._stop = threading.Event()
self._lock = threading.Lock() # чтение (loop)
self._send_lock = threading.Lock() # запись (ввод, отдельно:
# сокет полнодуплексный, RC4-потоки направлений независимы —
# ввод не должен ждать 15-секундного блокирующего read)
self._thread = None
self.last_update = time.time()
self.updates = 0
self._clip = None # ClipChannel (логика CLIPRDR, без сокета)
self._clip_reasm = bytearray() # сборка фрагментов канала
@property
def width(self):
return self.frame.width
@property
def height(self):
return self.frame.height
def start(self, fps=10):
import threading as _th
self._fps = max(1, min(15, int(fps or 10)))
self._thread = _th.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
try:
self.conn.close()
except Exception: # noqa: BLE001
pass
def _send_input(self, payload):
from . import mcs as _mcs
from . import security as _sec
crypto = getattr(self, "crypto", None)
if crypto is not None:
# Legacy: input PDU шифруются как share (флаги 0x0808,
# БЕЗ SEC_LOGON_INFO — иначе сервер рвёт соединение).
payload = _sec.wrap_share(crypto, payload)
with self._send_lock:
_mcs.write_mcs(self.conn,
_mcs.send_data(self.user_id, self.io_channel,
payload))
def _send_channel(self, clip_pdu):
"""Clipboard PDU кусками: length — длина данных без 8-байт шапки,
FIRST/LAST по краям, каждый кусок шифруем как share."""
import struct as _s
from . import clip as _clip
from . import mcs as _mcs
from . import security as _sec
from . import tpkt as _t
if not self.clip_channel:
raise _t.RdpError("нет канала cliprdr")
crypto = getattr(self, "crypto", None)
data = bytes(clip_pdu)
total = len(data)
frags = [data[i:i + _clip.CHAN_CHUNK]
for i in range(0, max(len(data), 1), _clip.CHAN_CHUNK)]
with self._send_lock:
for i, frag in enumerate(frags):
flags = _clip.CHAN_FLAG_SHOW_PROTOCOL
if i == 0:
flags |= _clip.CHAN_FLAG_FIRST
if i == len(frags) - 1:
flags |= _clip.CHAN_FLAG_LAST
chunk = _s.pack("<LL", total, flags) + frag
if crypto is not None:
chunk = _sec.wrap_share(crypto, chunk)
_mcs.write_mcs(self.conn,
_mcs.send_data(self.user_id,
self.clip_channel, chunk))
def _clip_handshake(self):
"""CLIPRDR CAPS + MONITOR_READY после финализации (как FreeRDP).
Порядок: сначала читаем спонтанные CAPS+READY сервера (2-3с),
потом шлём свои — иначе сервер не шлёт DATA_REQUEST.
Ошибки не роняют сессию: без канала — paste_unicode.
"""
from . import clip as _clip
if not self.clip_channel:
return
try:
if self._clip is None:
self._clip = _clip.ClipChannel()
self._clip_drain_spont()
for pdu in self._clip.connect_pdus():
self._send_channel(pdu)
except Exception as e: # noqa: BLE001
logger.warning("remotedesk rdp clip handshake: %s",
str(e)[:100])
def _clip_drain_spont(self, seconds=3.0):
"""Прочитать спонтанные CAPS+READY сервера до своих CAPS.
Чужие PDU роутятся в штатные обработчики (_on_fast/_on_mcs),
а не выкидываются: начальный пейнт приходит в этом же окне.
Ошибки глотаем — drain не роняет сессию.
"""
import time as _time
from . import tpkt as _t
c = self.conn
if not hasattr(c, "read_pdu"):
return # тестовый коннект без чтения — сразу к своим CAPS
try:
old_timeout = c.sock.gettimeout()
except Exception: # noqa: BLE001
old_timeout = None
try:
c.sock.settimeout(2)
except Exception: # noqa: BLE001
pass
t0 = _time.time()
try:
while _time.time() - t0 < seconds:
try:
kind, pdu = c.read_pdu()
except _t.RdpTimeout:
continue
except _t.RdpError:
break
if kind != "slow":
try:
self._on_fast(bytes(pdu))
except Exception: # noqa: BLE001
pass
continue
try:
# _on_mcs сам демультиплексирует: clip-канал -> _on_clip
# (спонтанные CAPS+READY как раньше), share -> кадр.
self._on_mcs(bytes(pdu))
except Exception: # noqa: BLE001
pass
finally:
try:
if old_timeout is not None:
c.sock.settimeout(old_timeout)
except Exception: # noqa: BLE001
pass
def clip_offer(self, text):
"""Объявить исходящий текст (FORMAT_LIST). Вернуть True если
ушло по каналу, False — нет канала (вызывающий падает в
paste_unicode эмуляцию). Пустое/None — False без отправки."""
if not text or not self.clip_channel:
return False
try:
if self._clip is None:
from . import clip as _clip
self._clip = _clip.ClipChannel()
for pdu in self._clip.offer(text):
self._send_channel(pdu)
return True
except Exception as e: # noqa: BLE001
logger.warning("remotedesk rdp clip offer: %s", str(e)[:100])
return False
def clip_take_inbox(self):
"""Забрать входящие тексты сервера (для WS-sender)."""
clip = getattr(self, "_clip", None)
if clip is None:
return []
try:
return clip.take_inbox()
except Exception: # noqa: BLE001
return []
def key(self, scancode, down=True, extended=False):
from . import orders as _o
ev = _o.slowpath_key(scancode, down, extended)
self._send_input(_o.client_input(self._share_id(), ev))
def combo(self, events):
"""Атомарное комбо: готовые input events (slowpath_key/mouse)
одной INPUT PDU — сервер применяет строго по порядку.
Одиночные key/pointer шлют по одному событию на PDU; пачка
из 6 отдельных key через WS приходила рваной (rate-limit ел
середину — CAD не работал). Комбо неделимо.
"""
from . import orders as _o
evs = list(events)
if evs:
self._send_input(_o.client_input(self._share_id(), *evs))
def pointer(self, x, y, buttons=0, wheel=0):
from . import orders as _o
x = max(0, min(int(x), self.frame.width - 1))
y = max(0, min(int(y), self.frame.height - 1))
self._ptr_pos = (x, y)
events = []
if wheel:
events.append(_o.slowpath_mouse(x, y, 0, wheel))
else:
# UI шлёт абсолютное состояние кнопок — диффим с прошлым
# и шлём press/release переходами (RDP нужны PTRFLAGS_DOWN).
prev = getattr(self, "_btn_state", 0)
changed = (int(buttons) ^ prev) & 0x07
for bit, mask in ((0, 1), (1, 2), (2, 4)):
if changed & mask:
events.append(_o.slowpath_mouse(
x, y, mask, 0, down=bool(int(buttons) & mask)))
self._btn_state = int(buttons) & 0x07
if not events:
events.append(_o.slowpath_mouse(x, y, 0, 0)) # MOVE
for ev in events:
self._send_input(_o.client_input(self._share_id(), ev))
def paste_unicode(self, pairs):
"""Вставка текста: [[flags, codepoint]] unicode-событиями.
Формат пар отличим от scancode-ключей [domCode, down]:
первое поле — число-флаг, не строка кода.
"""
from . import orders as _o
evs = []
for item in list(pairs or [])[:512]:
try:
flags, cp = (list(item) + [0, 0])[:2]
except Exception: # noqa: BLE001
continue
try:
cp = int(cp)
rel = bool(int(flags) & 0x8000)
except (TypeError, ValueError):
continue
# Astral (>0xFFFF): surrogate pair двумя событиями.
if cp > 0xFFFF:
cp -= 0x10000
evs.append(_o.slowpath_unicode(0xD800 + (cp >> 10),
rel))
evs.append(_o.slowpath_unicode(0xDC00 + (cp & 0x3FF),
rel))
else:
evs.append(_o.slowpath_unicode(cp & 0xFFFF, rel))
if evs:
self._send_input(_o.client_input(self._share_id(), *evs))
@property
def pointer_pos(self):
"""Последняя позиция мыши (для курсор-оверлея фронта)."""
return getattr(self, "_ptr_pos", (0, 0))
def _share_id(self):
return getattr(self, "_share_id_val", 0x03EA)
def sync(self, toggle_flags=0):
"""SYNC event (RDP_INPUT_SYNCHRONIZE), шлём первым."""
from . import orders as _o
self._send_input(_o.client_input(
self._share_id(), _o.slowpath_sync(toggle_flags)))
def _loop(self):
from . import tpkt as _t
c = self.conn
try:
while not self._stop.is_set():
try:
with self._lock:
kind, pdu = c.read_pdu()
except _t.RdpTimeout:
# Сервер молчит между апдейтами — продолжаем ждать.
continue
except _t.RdpError as e:
logger.info("remotedesk rdp: конец: %s", e)
break
try:
if kind == "fast":
self._on_fast(pdu)
else:
self._on_mcs(pdu)
except _t.RdpError as e:
logger.warning("remotedesk rdp pdu: %s", e)
except Exception as e: # noqa: BLE001
logger.info("remotedesk rdp loop: %s: %s",
type(e).__name__, str(e)[:150])
def _on_fast(self, pdu):
"""Fast-Path Output: updates -> rects во фрейм."""
from . import fastpath as _fp
if getattr(self, "_fp", None) is None:
self._fp = _fp.FastPathParser(self.bpp)
rects, palette = self._fp.feed(pdu)
if palette is not None:
self.palette = palette
self._apply_rects(rects)
def _apply_rects(self, rects):
"""Положить rects [(x,y,w,h,rgb888)] во фрейм, посчитать update.
Клиппинг + проверка длины: иначе порча фреймбуфера
со сдвигом строк. Битые rects дропаем молча.
"""
import time as _time
if not rects:
return
fw, fh = self.frame.width, self.frame.height
for (x, y, w, h, rgb) in rects:
try:
x, y, w, h = int(x), int(y), int(w), int(h)
except (TypeError, ValueError):
continue
if w <= 0 or h <= 0:
continue
if len(rgb) != w * h * 3:
continue
# Клиппинг по фрейму (сервер иногда шлёт за край).
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(fw, x + w), min(fh, y + h)
if x1 <= x0 or y1 <= y0:
continue
if x0 == x and y0 == y and x1 == x + w and y1 == y + h:
loc = rgb
else:
rows = []
for r in range(y0 - y, y1 - y):
rows.append(rgb[r * w * 3 + (x0 - x) * 3:
r * w * 3 + (x1 - x) * 3])
loc = b"".join(rows)
cw, chh = x1 - x0, y1 - y0
for j in range(chh):
off = ((y0 + j) * fw + x0) * 3
row = loc[j * cw * 3:(j + 1) * cw * 3]
self.frame.fb[off:off + cw * 3] = row[:cw * 3]
self.frame.apply([(x0, y0, cw, chh)])
self.last_update = _time.time()
self.updates += 1
def _on_mcs(self, mcs):
from . import orders as _o
from . import tpkt as _t
# MCS SendData: клиентский Request 0x64, серверный Indication 0x68.
if not mcs or mcs[0] not in (0x64, 0x68):
return
channel, body = _strip_send_data_ex(mcs)
if body is None:
return
crypto = getattr(self, "crypto", None)
if crypto is not None:
body = _decrypt_share(crypto, body)
if body is None:
return
# Виртуальный канал cliprdr — не share PDU: разбираем отдельно
# (иначе share-парсер молча выкинет как pdu_type != 7).
if (self.clip_channel is not None
and channel == self.clip_channel):
try:
self._on_clip(body)
except _t.RdpError as e:
logger.warning("remotedesk rdp clip: %s", e)
return
if len(body) < 18:
return
# Структурный разбор (без поиска маркеров): shareControlHeader(6:
# totalLength + pduType + pduSource) — ждём Data PDU (pduType 7),
# затем sharedDataHeader(12) с pduType2. Update PDU: pduType2 = 2.
import struct as _s
pdu_type = _s.unpack("<H", body[2:4])[0] & 0x0F
if pdu_type != 7:
return
pdu_type2 = body[14]
# type 9 (PALETTE) — обновляем палитру 8bit, кадра нет.
# Проверка ДО фильтра pdu_type2==2 (иначе недостижимо).
if pdu_type2 == 9:
try:
self.palette = _o.parse_palette(body[18:])
except _t.RdpError:
pass
return
if pdu_type2 != 2:
return
# body[18:] = TS_UPDATE_PDU (updateType 1=bitmap, 6=orders).
# uncompressedLength check мягкий (сервер иногда врёт на +-байты).
try:
rects = _o.parse_update_pdu(body[18:], self.bpp,
self.palette)
except _t.RdpError:
return
self._apply_rects(rects)
def _on_clip(self, data):
"""Канальные данные cliprdr: сборка фрагментов -> ClipChannel.
CHANNEL_PDU_HEADER: length(4, вся длина С шапкой) + flags(4).
FIRST сбрасывает сборку, LAST отдаёт целое на разбор (в целом
может быть несколько PDU подряд — разбираем циклом).
"""
import struct as _s
from . import clip as _clip
from . import tpkt as _t
if len(data) < 8:
raise _t.RdpError("короткий channel pdu")
total, flags = _s.unpack("<LL", data[:8])
# total = длина данных БЕЗ шапки (как шлём сами); пустой кусок
# (total=0) допустим, кап — как у inbox.
if total > 4 * 1048576 + 8:
raise _t.RdpError("плохая длина channel pdu: %d" % total)
frag = data[8:]
if flags & _clip.CHAN_FLAG_FIRST:
self._clip_reasm = bytearray()
self._clip_reasm += frag
if len(self._clip_reasm) > 4 * 1048576 + 8:
self._clip_reasm = bytearray()
raise _t.RdpError("переполнение сборки clip")
if not (flags & _clip.CHAN_FLAG_LAST):
return
blob = bytes(self._clip_reasm)
self._clip_reasm = bytearray()
if self._clip is None:
self._clip = _clip.ClipChannel()
pos = 0
while pos + 8 <= len(blob):
_t2, _f2, ln = _s.unpack("<HHL", blob[pos:pos + 8])
if ln > _clip.IN_TEXT_MAX + 8 or pos + 8 + ln > len(blob):
break
pdu = blob[pos:pos + 8 + ln]
pos += 8 + ln
try:
for resp in self._clip.on_pdu(pdu):
self._send_channel(resp)
except _t.RdpError as e:
logger.warning("remotedesk rdp clip pdu: %s", e)
break
def connect(host, port=3389, username="", password="", domain="",
mode="any", width=1024, height=768, bpp=32, timeout=10,
cert_pin="", ignore_cert=False, fps=10, compat="modern"):
"""Полное подключение. Вернуть RdpSession (поток уже запущен).
compat: "modern" (RDP 10.7 Core, 40|128bit) или "legacy" (RDP 5.0,
только 40bit, 8bit — для Win XP/2003 и старых терминалов).
"""
from . import mcs as _mcs
from . import security as _sec
from . import tpkt as _t
from ..rfb import frame as _frame
if compat == "legacy":
# XP: только legacy rdp security, без NLA/TLS попыток.
mode = "rdp"
bpp = 8
conn, selected = _t.negotiate(host, port, mode, username, timeout)
logger.info("remotedesk rdp: negotiate %s:%s -> %s (mode=%s)",
host, port, selected, mode)
try:
if selected in ("tls", "nla", "nla-ext"):
der = conn.start_tls(host)
logger.info("remotedesk rdp: TLS ok (%s)",
selected)
if not ignore_cert and not cert_pin:
# TOFU: fingerprint показать вызывающему через исключение.
fp = "sha256:" + _cert_sha256(der)
conn.close()
from ..rfb import proto as _rp # reuse NeedPin-идеи
raise CertPinNeeded(fp)
if cert_pin:
fp = "sha256:" + _cert_sha256(der)
if cert_pin != fp:
conn.close()
raise _t.RdpError(
"сертификат НЕ совпал с пином "
"(сервер дал %s)" % fp)
if selected in ("nla", "nla-ext"):
try:
_sec.credssp_handshake(
conn, username, password, domain,
early_auth=(selected == "nla-ext"))
except _t.RdpError as e:
raise _t.RdpError("CredSSP (%s): %s" % (selected, e))
except ValueError as e:
raise _t.RdpError("CredSSP (%s): %s" % (selected, e))
logger.info("remotedesk rdp: CredSSP ok (%s)", selected)
# MCS Connect Initial.
ci = _mcs.build_connect_initial(username, width, height, bpp,
compat, selected)
with _mcs_lock(conn):
_mcs.write_mcs(conn, ci)
resp = conn.read_mcs()
srv = _mcs.parse_connect_response(resp)
# Erect + Attach.
with _mcs_lock(conn):
_mcs.write_mcs(conn, _mcs.erect_domain())
_mcs.write_mcs(conn, _mcs.attach_user())
confirm = conn.read_mcs()
user_id = _parse_attach_confirm(confirm)
io_ch = int(srv.get("io_channel") or 1003)
# Канал cliprdr — первый из заявленных в NET (порядок SC_NET =
# порядок дефов; channels[0] наш, если сервер его принял).
clip_ch = None
srv_channels = [int(c) for c in (srv.get("channels") or [])]
if srv_channels:
clip_ch = srv_channels[0]
# Join: user + io + clip (если есть).
with _mcs_lock(conn):
for ch in [user_id, io_ch] + ([clip_ch] if clip_ch else []):
_mcs.write_mcs(conn, _mcs.channel_join(user_id, ch))
conn.read_mcs() # join confirm (проверка мягкая)
# Legacy rdp security: Security Exchange + Client Info.
# NLA/TLS: Client Info открытым текстом до Demand Active.
crypto = None
if selected == "rdp":
crypto = _legacy_security_exchange(
conn, user_id, io_ch, srv, username, password, domain)
else:
_send_client_info_plain(conn, user_id, io_ch, username,
password, domain)
# Confirm Active (capabilities минимум) -> Demand Active.
# Возвращает shareId сервера — нужен для input PDU.
share_id = _confirm_active(conn, user_id, io_ch, crypto, width,
height)
fr = _frame.Frame(width, height)
sess = RdpSession(conn, {"width": fr.width, "height": fr.height,
"selected": selected},
fr, user_id, io_ch, bpp, selected, clip_ch)
sess.crypto = crypto
sess._share_id_val = share_id
try:
sess._clip_handshake() # CLIPRDR CAPS + READY (не роняет)
except Exception: # noqa: BLE001
pass
sess.start(fps)
return sess
except Exception:
try:
conn.close()
except Exception: # noqa: BLE001
pass
raise
class CertPinNeeded(Exception):
"""Сертификат сервера неизвестен — нужен TOFU-пин (как NeedPin в SSH)."""
def __init__(self, fp):
super().__init__("нужен cert пин: %s" % fp)
self.fp = fp
def _cert_sha256(der):
import hashlib as _h
import base64 as _b
if not der:
return _b.b64encode(b"\x00" * 32).decode().rstrip("=")
return _b.b64encode(_h.sha256(der).digest()).decode().rstrip("=")
def _mcs_lock(conn):
return conn._lock if hasattr(conn, "_lock") else _DummyLock()
class _DummyLock:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _parse_attach_confirm(payload):
"""MCS Attach User Confirm -> user channel id (PER, см. mcs.py)."""
from . import mcs as _mcs
return _mcs.parse_attach_confirm(payload)
def _legacy_security_exchange(conn, user_id, io_ch, srv, username,
password, domain=""):
"""Legacy: Security Exchange (RSA) -> Client Info (RC4).
Возвращает RdpCrypto (ключи сессии) для шифра дальнейшего трафика.
Сервер отвечает License Error (игнорим) и Demand Active.
"""
import os as _os
from . import mcs as _mcs
from . import security as _sec
from . import tpkt as _t
cert = srv.get("cert") or b""
if not cert or not (srv.get("server_random") or b""):
raise _t.RdpError("сервер не прислал сертификат (legacy rdp)")
rnd = _os.urandom(32)
exch = _sec.build_security_exchange(cert, rnd)
_mcs.write_mcs(conn, _mcs.send_data(user_id, io_ch, exch))
crypto = _sec.RdpCrypto(rnd, srv["server_random"],
int(srv.get("enc_method") or 2))
info = _sec.build_client_info(username, password, domain)
_mcs.write_mcs(conn, _mcs.send_data(
user_id, io_ch, _sec.wrap_encrypted(crypto, info)))
return crypto
def _send_client_info_plain(conn, user_id, io_ch, username, password,
domain=""):
"""Client Info без шифра (NLA/TLS): SEC_INFO_PKT + TS_INFO_PACKET.
Канал уже защищён TLS (CredSSP внутри), шифровать нечего — шлём
открытые флаги 0x40 + build_client_info. crypto для сессии нет.
"""
import struct as _s
from . import mcs as _mcs
from . import security as _sec
info = _sec.build_client_info(username, password, domain)
_mcs.write_mcs(conn, _mcs.send_data(
user_id, io_ch, _s.pack("<L", 0x40) + info))
def _confirm_active(conn, user_id, io_ch, crypto=None, width=1024,
height=768):
"""Confirm Active PDU (capabilities минимум) + ждать Demand Active.
Confirm Active: shareId(4) + originator(2) + lenSourceDesc(2) +
lenCombinedCaps(2) + sourceDesc + caps. Шлём пустые General(8B) +
Bitmap(28B) + Order(88B, минимум: orderSupport[32]=0 кроме DSTBLT?) —
сервер обязан откатиться на Bitmap Updates. Перед Demand Active сервер
шлёт SERVER_LICENSE_REQUEST — отвечаем CLIENT_LICENSE_INFO один раз
(иначе сервер молчит). Затем читаем до Demand Active (pduType2=1)
и отвечаем Confirm Active.
"""
import struct as _s
import time as _time
from . import mcs as _mcs
from . import security as _sec
from . import tpkt as _t
conn.sock.settimeout(15)
t0 = _time.time()
demand = None
lic_replied = False
while _time.time() - t0 < 15:
try:
raw = conn.read_mcs()
except _t.RdpError:
break
# MCS Send Data Indication: снимаем, ищем share PDU.
body = _strip_send_data(raw)
if body is None:
continue
# License PDU сервера (SERVER_LICENSE_REQUEST / LICENSE_ERROR):
# flags u32 с битом 0x80, без ENCRYPT. На запрос отвечаем один раз
# CLIENT_LICENSE_INFO — иначе сервер молчит (нет Demand Active).
if len(body) >= 4:
lic_flags = _s.unpack("<L", body[:4])[0]
if (lic_flags & 0x0080) and not (lic_flags & 0x0008):
if not lic_replied and crypto is not None:
lic = _sec.wrap_license(
crypto, _sec.build_license_info())
_mcs.write_mcs(
conn, _mcs.send_data(user_id, io_ch, lic))
lic_replied = True
continue
if crypto is not None:
body = _decrypt_share(crypto, body)
if body is None:
continue
# Demand Active PDU (MS-RDPBCGR 2.2.8.1.1.1.2): после SEC-заголовка
# идёт shareControlHeader(6: totalLength + pduType + pduSource) +
# shareId(4) + caps. Demand Active: pduType = 0x11 (offset 2).
# (pduType2 есть только у Data PDU — здесь его нет.)
if len(body) >= 4 and body[2] == 0x11:
demand = body
break
# License Error PDU (pduType=0xFF?) — пропускаем.
if demand is None:
raise _t.RdpError("нет Demand Active от сервера")
share_id = _s.unpack("<L", demand[6:10])[0] # shareId сервера
caps = _build_caps_min(width, height)
# TS_CONFIRM_ACTIVE_PDU (MS-RDPBCGR 2.2.1.13.2.2): shareControlHeader(6:
# totalLength + pduType=ConfirmActive(3)+version + pduSource) +
# shareId(4) + originator(2) + lenSourceDesc(2) + lenCombinedCaps(2) +
# sourceDesc + caps.
# pduSource = io-канал (не user_id), originator = 0x03EA,
# lenCombinedCaps — длина caps без sourceDesc.
sdesc = _confirm_source_desc()
confirm_body = (_s.pack("<LHHH", share_id, 0x03EA, len(sdesc),
len(caps))
+ sdesc + caps)
confirm = (_s.pack("<HHH", 6 + len(confirm_body), 0x10 | 3, io_ch)
+ confirm_body)
full = confirm
if crypto is not None:
full = _sec.wrap_share(crypto, full)
_mcs.write_mcs(conn, _mcs.send_data(user_id, io_ch, full))
# Synchronize -> Control(4) -> Control(1) -> Font List, шифруем share.
from . import orders as _o
for pdu in (_o.client_synchronize(share_id, user_id),
_o.client_control(share_id, 4),
_o.client_control(share_id, 1),
_o.client_font_list(share_id)):
out = _sec.wrap_share(crypto, pdu) if crypto is not None else pdu
_mcs.write_mcs(conn, _mcs.send_data(user_id, io_ch, out))
return share_id
def _strip_send_data(raw):
"""Снять MCS SendData Indication. Вернуть userData или None."""
_, body = _strip_send_data_ex(raw)
return body
def _strip_send_data_ex(raw):
"""Снять MCS SendData Indication. Вернуть (channel_id, userData).
channel_id нужен демультиплексору виртуальных каналов (cliprdr):
share идёт с io-канала, clip — со своего. Старый _strip_send_data —
обёртка, сигнатуру не меняем (зовёт _confirm_active).
"""
import struct as _s
# Клиент шлёт Request (0x64), сервер отвечает Indication (0x68).
if not raw or raw[0] not in (0x64, 0x68):
return None, None
# 64 | initiator(2) | channel(2) | 70 | per_len | data.
if len(raw) < 7:
return None, None
channel = _s.unpack(">H", raw[3:5])[0]
p = 1 + 2 + 2 + 1
b0 = raw[p]
if b0 < 128:
return channel, raw[p + 1:]
return channel, raw[p + 2:]
def _decrypt_share(crypto, body):
"""Снять SEC_ENCRYPT с share PDU. Вернуть открытый body или None."""
import struct as _s
if len(body) < 12:
return None
flags = _s.unpack("<L", body[:4])[0]
if not (flags & 0x0008):
return body # не шифровано
sig, enc = body[4:12], body[12:]
dec = crypto.decrypt(enc)
return dec
def _build_caps_min(width=1024, height=768):
"""Combined Capabilities: General + Bitmap + Order + BitmapCacheHostSupport.
Минимальный рабочий набор {General, Bitmap, Order, HostSupport}:
без сета 0x0D или с нулевым Order сервер рвёт финализацию.
Width/height сессии подставляются в Bitmap, остальное — константы.
"""
import struct as _s
# General (type 1, len 24): osMajor=4, osMinor=7, ver=0x0200,
# extraFlags=0x0415, refreshRect=1, suppressOutput=1.
gen = bytes.fromhex("01001800") + bytes.fromhex(
"0400070000020000000015040000000000000101")
# Bitmap (type 2, len 28): 32bpp, resize=1, compression=1,
# drawingFlags=0x0E, multiRect=1; width/height — свои.
bmp = (_s.pack("<HH", 2, 28) + _s.pack(
"<HHHH", 32, 1, 1, 1) + _s.pack("<HH", width, height)
+ bytes.fromhex("000001000100000e01000000"))
# Order (type 3, len 88): эталон целиком (orderSupport с флагами
# DSTBLT/PATBLT/SCRBLT/MEMBLT/LINE/MEM3BLT/TRIANGLES/...).
ordcaps = bytes.fromhex(
"03005800000000000000000000000000000000000000000001001400"
"000001000000aa000101010000000000010000000000000000000100"
"00000100000000000000000000000400000000000084030000000000"
"e9fd0000")
# BitmapCacheHostSupport (type 0x0D, len 88): эталон целиком
# (body 84: cacheFlags=0x13D, bmjhCacheHandle=0x409, bmjhCacheCount=4,
# остальное нули).
hostsup = bytes.fromhex(
"0d0058003d0100000904000004000000000000000c00000000000000"
"00000000000000000000000000000000000000000000000000000000"
"00000000000000000000000000000000000000000000000000000000"
"00000000")
caps = gen + bmp + ordcaps + hostsup
return _s.pack("<HH", 4, len(caps) + 4) + caps
def _confirm_source_desc():
"""Source descriptor Confirm Active."""
return b"FREERDP\x00"
rdp/clip.py 12.7 КБ
"""remotedesk.rdp.clip — буфер обмена через канал cliprdr (MS-RDPECLIP).
Текст туда-обратно (CF_UNICODETEXT=13, fallback CF_TEXT=1).
Файлы (FileGroupDescriptorW) — следующим заходом, канал тот же.
Clipboard PDU: msgType(2 LE) + msgFlags(2 LE) + dataLen(4 LE) + payload.
Сообщения: MONITOR_READY=1, FORMAT_LIST=2, FORMAT_LIST_RESPONSE=3,
FORMAT_DATA_REQUEST=4, FORMAT_DATA_RESPONSE=5, CLIP_CAPS=7.
Флаги ответа: CB_RESPONSE_OK=1, CB_RESPONSE_FAIL=2; в FORMAT_LIST
флаг CB_ASCII_NAMES=4 означает однобайтные имена (иначе UTF-16LE).
Обмен (обе стороны шлют CAPS + MONITOR_READY после финализации):
- копия клиент->сервер: мы шлём FORMAT_LIST([13]); сервер позже шлёт
DATA_REQUEST(13); мы отвечаем DATA_RESPONSE с UTF-16LE текстом.
- копия сервер->клиент: сервер шлёт FORMAT_LIST; мы отвечаем OK и тут
же шлём DATA_REQUEST(13|1); сервер отвечает DATA_RESPONSE -> inbox.
Канальный транспорт (CHANNEL_PDU_HEADER, MS-RDPBCGR §2.2.6.1.1):
length(4 LE, длина канальных ДАННЫХ без 8-байт шапки — серверные пакеты:
CAPS total=24 при fraglen=24) + flags(4 LE: FIRST=1, LAST=2) + кусок.
Сегментация: кусок <= 12000 байт (влезает в MCS SendData с запасом).
Сборка: FIRST сбрасывает буфер, LAST отдаёт целое на разбор.
"""
import struct
# --- константы ---
CB_MONITOR_READY = 1
CB_FORMAT_LIST = 2
CB_FORMAT_LIST_RESPONSE = 3
CB_FORMAT_DATA_REQUEST = 4
CB_FORMAT_DATA_RESPONSE = 5
CB_CLIP_CAPS = 7
CB_RESPONSE_OK = 0x0001
CB_RESPONSE_FAIL = 0x0002
CB_ASCII_NAMES = 0x0004
CF_TEXT = 1
CF_UNICODETEXT = 13
CHAN_FLAG_FIRST = 0x01
CHAN_FLAG_LAST = 0x02
# SHOW_PROTOCOL (0x10): ставим на каждый чанк, раз заявлен в дефе канала.
# Без него сервер молча дропает наши PDU.
CHAN_FLAG_SHOW_PROTOCOL = 0x10
CHAN_CHUNK = 12000 # кусок канальных данных на один MCS SendData
IN_TEXT_MAX = 1024 * 1024 # кап входящего текста (защита от spam- decline)
OUT_TEXT_MAX = 131072 # кап исходящего (chars; ~256КБ UTF-16)
CLIP_CHANNEL_NAME = "cliprdr"
def _hdr(msg_type, msg_flags, payload):
return struct.pack("<HHL", msg_type, msg_flags, len(payload)) + payload
def build_caps(flags=0x00000002):
"""CB_CLIP_CAPS: 1 сет, GENERAL v2 + флаги.
CLIPRDR_GENERAL_CAPABILITY: type(2)=1 + length(2)=12 + version(4)
+ flags(4) — version именно UINT32 (FreeRDP/ironrdp, MS-RDPECLIP
§2.2.2.1; с UINT16 сервер не разберёт сет).
flags по умолчанию LONG_FORMAT_NAMES (0x02); FreeRDP отвечает
серверу пересечением (обычно 0x2E/0x3E — файловые биты).
"""
cap = struct.pack("<HHLL", 1, 12, 2, int(flags))
payload = struct.pack("<HH", 1, 0) + cap
return _hdr(CB_CLIP_CAPS, 0, payload)
def build_monitor_ready():
return _hdr(CB_MONITOR_READY, 0, b"")
def build_format_list(fmt_ids=(CF_UNICODETEXT,)):
"""CB_FORMAT_LIST: entries БЕЗ numFormats (байт-в-байт FreeRDP).
Entries без numFormats: только [formatId(4) + wszNUL].
С numFormats впереди сервер читал анонс [13] как [{id:1}]
и не присылал DATA_REQUEST.
"""
payload = b""
for fid in fmt_ids:
payload += struct.pack("<L", int(fid)) + b"\x00\x00"
return _hdr(CB_FORMAT_LIST, 0, payload)
def build_format_list_response_ok():
return _hdr(CB_FORMAT_LIST_RESPONSE, CB_RESPONSE_OK, b"")
def build_format_data_request(fmt_id):
return _hdr(CB_FORMAT_DATA_REQUEST, 0, struct.pack("<L", int(fmt_id)))
def build_format_data_response_ok(text, fmt_id=CF_UNICODETEXT):
if fmt_id == CF_UNICODETEXT:
data = (text or "").encode("utf-16-le") + b"\x00\x00"
else: # CF_TEXT: русская Windows ждёт cp1251
data = (text or "").encode("cp1251", errors="replace") + b"\x00"
return _hdr(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_OK, data)
def build_format_data_response_fail():
return _hdr(CB_FORMAT_DATA_RESPONSE, CB_RESPONSE_FAIL, b"")
def parse_pdu(blob):
"""Разобрать одну Clipboard PDU. Вернуть (type, flags, payload).
Бросает RdpError на обрезанном.
"""
from .tpkt import RdpError
if len(blob) < 8:
raise RdpError("короткая clip pdu")
t, f, ln = struct.unpack("<HHL", blob[:8])
if ln > IN_TEXT_MAX + 8:
raise RdpError("clip pdu слишком большая: %d" % ln)
if len(blob) < 8 + ln:
raise RdpError("обрезанная clip pdu")
return t, f, blob[8:8 + ln]
def parse_format_list(payload, ascii_names=False):
"""FORMAT_LIST payload -> [(formatId, name)].
Два стиля на проводе (оба валидируются ТОЧНЫМ потреблением):
- FreeRDP-style: entries сразу [id + wszNUL...] без numFormats
(так шлёт эталон и, видимо, rdpclip);
- spec-style: numFormats(4) + entries (так, возможно, шлёт mstsc).
Сначала пробуем spec-style (numFormats + точное потребление),
иначе сканируем entries с offset 0. Иначе анонс [13] читался бы
как [{id:1}] (CF_TEXT) — класс бага выше.
"""
from .tpkt import RdpError
if len(payload) < 0:
raise RdpError("короткий format list")
# Попытка 1: spec-style с numFormats.
if len(payload) >= 4:
try:
(n,) = struct.unpack("<L", payload[:4])
if 0 < n <= 1024:
entries, end = _scan_entries(payload, 4, n, ascii_names)
if end == len(payload):
return entries
except RdpError:
pass
# Попытка 2: FreeRDP-style, entries с offset 0.
entries, end = _scan_entries(payload, 0, 1024, ascii_names)
if end == len(payload) and (entries or len(payload) == 0):
return entries
raise RdpError("плохой format list")
def _scan_entries(payload, pos, limit, ascii_names):
"""Сканировать entries с pos. Вернуть ([(fid, name)], end_pos).
Бросает RdpError на обрезанном entry (для spec-style это значит
«не тот стиль», для scan-style — битый payload).
"""
from .tpkt import RdpError
out = []
for _ in range(limit):
if pos == len(payload):
break
if pos + 4 > len(payload):
raise RdpError("обрезанный format entry")
(fid,) = struct.unpack("<L", payload[pos:pos + 4])
pos += 4
name = ""
if ascii_names:
end = payload.find(b"\x00", pos)
if end < 0:
raise RdpError("обрезанное ascii-имя")
try:
name = payload[pos:end].decode("ascii", errors="replace")
except Exception: # noqa: BLE001
name = ""
pos = end + 1
else:
end = pos
while end + 1 < len(payload):
if payload[end] == 0 and payload[end + 1] == 0:
break
end += 2
if end + 1 >= len(payload):
# Хвост без NUL: FreeRDP-ридер такое отбрасывает молча;
# здесь — конец (entry без имени невалиден полностью).
raise RdpError("обрезанное unicode-имя")
try:
name = payload[pos:end].decode("utf-16-le",
errors="replace")
except Exception: # noqa: BLE001
name = ""
pos = end + 2
out.append((fid, name))
return out, pos
def parse_data_response(payload, fmt_id):
"""DATA_RESPONSE payload -> str (по запрошенному формату)."""
if fmt_id == CF_UNICODETEXT:
if len(payload) < 2:
return ""
if payload[-2:] == b"\x00\x00":
payload = payload[:-2]
return payload.decode("utf-16-le", errors="replace")
# CF_TEXT — cp1251 (русская Windows), как отправляем сами.
if payload[-1:] == b"\x00":
payload = payload[:-1]
try:
return payload.decode("cp1251")
except Exception: # noqa: BLE001
return payload.decode("latin-1", errors="replace")
def pick_text_format(formats):
"""Выбрать текстовый формат из серверного списка (или None)."""
ids = {fid for fid, _ in formats}
if CF_UNICODETEXT in ids:
return CF_UNICODETEXT
if CF_TEXT in ids:
return CF_TEXT
return None
class ClipChannel:
"""Логика CLIPRDR без сокета: вход PDU -> выход PDU + inbox.
Сокетный слой (RdpSession) кормит on_pdu() целыми PDU после сборки
фрагментов и отправляет вернувшийся список. offer() кладёт исходящий
текст и возвращает FORMAT_LIST для отправки.
"""
def __init__(self):
self.peer_ready = False
self.server_caps = None
self.server_formats = [] # последний FORMAT_LIST сервера
self.inbox = [] # готовые входящие тексты (забирает WS-sender)
self.out_text = None # исходящий текст (ответы на DATA_REQUEST)
self.want_fmt = None # формат ожидаемого DATA_RESPONSE
def connect_pdus(self):
"""CAPS + MONITOR_READY для отправки после финализации."""
return [build_caps(), build_monitor_ready()]
def offer(self, text):
"""Объявить исходящий текст. Вернуть [FORMAT_LIST PDU]."""
self.out_text = (text or "")[:OUT_TEXT_MAX]
return [build_format_list()]
def take_inbox(self):
out = self.inbox
self.inbox = []
return out
def on_pdu(self, pdu):
"""Обработать одну входящую PDU. Вернуть [ответные PDU]."""
t, flags, payload = parse_pdu(pdu)
if t == CB_CLIP_CAPS:
self.server_caps = bytes(payload[:16])
return []
if t == CB_MONITOR_READY:
self.peer_ready = True
return []
if t == CB_FORMAT_LIST:
try:
fmts = parse_format_list(payload,
bool(flags & CB_ASCII_NAMES))
except Exception: # noqa: BLE001
return [build_format_list_response_ok()]
self.server_formats = fmts
resp = [build_format_list_response_ok()]
want = pick_text_format(fmts)
if want is not None:
self.want_fmt = want
resp.append(build_format_data_request(want))
else:
self.want_fmt = None
return resp
if t == CB_FORMAT_LIST_RESPONSE:
return [] # наш анонс принят (или отвергнут — молчим)
if t == CB_FORMAT_DATA_REQUEST:
if len(payload) < 4:
return [build_format_data_response_fail()]
(want,) = struct.unpack("<L", payload[:4])
if self.out_text is None:
return [build_format_data_response_fail()]
if want in (CF_UNICODETEXT, CF_TEXT):
return [build_format_data_response_ok(self.out_text,
want)]
return [build_format_data_response_fail()]
if t == CB_FORMAT_DATA_RESPONSE:
if not (flags & CB_RESPONSE_OK):
self.want_fmt = None
return []
if self.want_fmt is None:
return [] # не просили — игнор
if len(payload) > IN_TEXT_MAX:
self.want_fmt = None
return []
try:
self.inbox.append(parse_data_response(payload,
self.want_fmt))
except Exception: # noqa: BLE001
pass
self.want_fmt = None
return []
return [] # неизвестное (файлы FileGroupDescriptor — v2) — молчим
rdp/fastpath.py 7.6 КБ
"""remotedesk.rdp.fastpath — Fast-Path Output (MS-RDPBCGR §2.2.9.1.2).
После финализации Windows шлёт кадры НЕ slow-path (TPKT+MCS), а
fast-path: fpOutputHeader(1) + PER-длина + updates. Каждый update:
updateHeader(1: code[0:3] + fragmentation[4:5] + compression[6:7]) +
[compressionFlags(1)] + size u16le + данные.
Декомпрессированный Bitmap-update — тот же TS_UPDATE_PDU, что в
slow-path (updateType + BITMAP_UPDATE), уходит в orders.parse_*.
Bulk-сжатие (compression==USED) не реализовано: такой update
пропускаем с warning.
Фрагментация (FIRST/NEXT/LAST, коды 2/3/1 по fastpath.h) собирается
в буфер; SINGLE (0) — сразу в разбор.
"""
import logging
import struct
from .orders import (_parse_orders, parse_bitmap_update, parse_palette,
parse_pointer_update)
from .tpkt import RdpError
logger = logging.getLogger("botmod_remotedesk")
# Update codes (FreeRDP fastpath.h).
FP_ORDERS = 0x0
FP_BITMAP = 0x1
FP_PALETTE = 0x2
FP_SYNCHRONIZE = 0x3
# Fragmentation (FreeRDP fastpath.h: SINGLE=0, LAST=1, FIRST=2, NEXT=3).
FRAG_SINGLE = 0
FRAG_LAST = 1
FRAG_FIRST = 2
FRAG_NEXT = 3
COMP_USED = 0x2
class FastPathParser:
"""Собирает фрагменты, разбирает updates в rects. Состояние на сессию."""
def __init__(self, bpp=32):
self.bpp = bpp
self.palette = None
self.pointer = None # последний pointer-апдейт (курсор-оверлей)
self._frag = bytearray()
self._frag_code = -1
self._skip_bulk_warned = False
def feed(self, pdu):
"""Один TS_FP_UPDATE_PDU. Вернуть (rects, palette_or_None).
Pointer-апдейты (5,6,8,9,10,11) в rects НЕ попадают — кладутся
в self.pointer (фронт рисует локальный курсор поверх канваса).
"""
if len(pdu) < 3:
raise RdpError("короткий fast-path pdu")
hdr = pdu[0]
if hdr & 0x03:
raise RdpError("fast-path action!=0: %02x" % hdr)
if (hdr >> 6) & 0x03:
raise RdpError("шифрованный fast-path не поддерживается")
# PER-длина: 1 байт (<0x80) или 2 байта.
b1 = pdu[1]
if b1 & 0x80:
if len(pdu) < 3:
raise RdpError("короткий fast-path len")
total = ((b1 & 0x7F) << 8) | pdu[2]
pos = 3
else:
total = b1
pos = 2
if total != len(pdu):
raise RdpError("длина fast-path %d != %d" % (total, len(pdu)))
rects = []
while pos + 3 <= len(pdu):
uh = pdu[pos]
code = uh & 0x0F
frag = (uh >> 4) & 0x03
comp = (uh >> 6) & 0x03
pos += 1
if comp == COMP_USED:
# compressionFlags — нужен bulk-decompress (MPPC),
# не реализован: пропускаем update целиком.
if pos + 3 > len(pdu):
raise RdpError("обрезанный bulk-заголовок")
pos += 1 # compressionFlags
if not self._skip_bulk_warned:
logger.warning("remotedesk rdp: fast-path bulk "
"пропущен (не реализован)")
self._skip_bulk_warned = True
elif comp != 0:
raise RdpError("неизвестный fast-path compression: %d"
% comp)
if pos + 2 > len(pdu):
raise RdpError("обрезанный fast-path size")
size = struct.unpack("<H", pdu[pos:pos + 2])[0]
pos += 2
if pos + size > len(pdu):
raise RdpError("обрезанный fast-path update")
data = pdu[pos:pos + size]
pos += size
if comp == COMP_USED:
continue
rects += self._update(code, frag, data)
return rects, self.palette
def _update(self, code, frag, data):
"""Фрагментация + диспетчер по коду."""
if frag == FRAG_SINGLE:
if self._frag:
logger.warning("remotedesk rdp: fast-path SINGLE при "
"незакрытом фрагменте — сброс")
self._frag = bytearray()
self._frag_code = -1
return self._dispatch(code, data)
if frag == FRAG_FIRST:
if self._frag:
logger.warning("remotedesk rdp: fast-path FIRST при "
"незакрытом фрагменте — сброс")
self._frag = bytearray(data)
self._frag_code = code
return []
if frag in (FRAG_NEXT, FRAG_LAST):
if not self._frag or self._frag_code != code:
logger.warning("remotedesk rdp: fast-path фрагмент без "
"начала — сброс")
self._frag = bytearray()
self._frag_code = -1
return []
self._frag += data
if frag == FRAG_LAST:
data, self._frag = bytes(self._frag), bytearray()
code, self._frag_code = self._frag_code, -1
return self._dispatch(code, data)
return []
# frag — 2 бита (0..3 покрыты выше): сюда не попасть.
raise RdpError("плохой fast-path fragment: %d" % frag)
def _dispatch(self, code, data):
"""Разобрать целый update. Вернуть rects."""
if code == FP_BITMAP:
# Битый bitmap дропаем целиком (паритет с orders/palette):
# иначе raise вылетает из feed() с потерей всего PDU.
try:
return parse_bitmap_update(data, self.bpp, self.palette)
except RdpError as e:
logger.warning("remotedesk rdp: bitmap: %s", e)
return []
if code == FP_PALETTE:
# FP_PALETTE update: палитра С updateType-префиксом (как
# slow-path body[18:]); parse_palette скипает свои 2 байта.
try:
self.palette = parse_palette(data[2:])
except RdpError as e:
logger.warning("remotedesk rdp: палитра: %s", e)
return []
if code == FP_SYNCHRONIZE:
return []
if code == FP_ORDERS:
# numberOrders(2) + orders (updateCode заменяет updateType).
try:
return _parse_orders(data, self.bpp)
except RdpError as e:
logger.warning("remotedesk rdp: orders: %s", e)
return []
if code in (5, 6, 8, 9, 10, 11):
# Pointer updates (fastpath.c FASTPATH_UPDATETYPE_*):
# 8=position, 5/6=system hidden/default, 9/10/11=color/cached/new.
# В rects не идут — только курсор-оверлей фронта.
upd = parse_pointer_update(code, data)
if upd:
self.pointer = upd
return []
# Surface commands (4) и прочее — пропускаем.
logger.debug("remotedesk rdp: fast-path update %d пропущен", code)
return []
rdp/mcs.py 17.2 КБ
"""remotedesk.rdp.mcs — MCS Connect + channel join (MS-RDPBCGR §2.2.1.3-9).
ASN.1 BER пишем руками (минимальный сабсет: INTEGER, OCTET STRING,
SEQUENCE, [APPLICATION n] IMPLICIT, BOOLEAN). Client Core Data —
минимум чтобы сервер откатился на Bitmap Updates (§план).
"""
import struct
from .tpkt import RdpError
# --- BER helpers ---
def _len(n):
if n < 128:
return bytes([n])
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(b)]) + b
def _tlv(tag, val):
return bytes([tag]) + _len(len(val)) + val
def _int(n):
if n == 0:
return _tlv(0x02, b"\x00")
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
if b[0] & 0x80:
b = b"\x00" + b
return _tlv(0x02, b)
def _oct(data):
return _tlv(0x04, data)
def _seq(*items):
return _tlv(0x30, b"".join(items))
def _app(n, data):
# BER long-form tag для [APPLICATION n], n > 30: 0x7F 0xNN.
# (Connect-Initial = [APPLICATION 101] -> 7F 65, а не 0x65!)
if n < 31:
return bytes([0x60 | n]) + _len(len(data)) + data
return bytes([0x7F, n & 0xFF]) + _len(len(data)) + data
# --- MCS PDU types (T.125) ---
# Connect-Initial ::= [APPLICATION 101] IMPLICIT SEQUENCE
# SendDataRequest ::= [APPLICATION 25], ChannelJoinRequest ::= [APPL 14].
def _domain_params(max_channels=34, max_users=2, max_tokens=0,
num_priorities=1, min_throughput=0, max_height=1,
max_pdu=65535, ver=2):
return _seq(_int(max_channels), _int(max_users), _int(max_tokens),
_int(num_priorities), _int(min_throughput),
_int(max_height), _int(max_pdu), _int(ver))
# --- GCC Conference Create Request (T.124 PER) ---
# H.221-ключ: "Duca". Блоки type+len UINT16 LE, порядок CORE/CLUSTER/SEC/NET.
_H221_CS_KEY = b"Duca"
_T124_OID = bytes([0, 0, 20, 124, 0, 1])
def _per_choice(n):
return bytes([n & 0xFF])
def _per_oid():
# OID без тега 06 (с тегом сервер рвёт Connect Initial).
return bytes([0x05, 0x00, 0x14, 0x7c, 0x00, 0x01])
def _per_length(n):
# n<=0x7F — 1 байт; иначе UINT16_BE(n | 0x8000).
if n <= 0x7F:
return bytes([n])
return struct.pack(">H", (n & 0x7FFF) | 0x8000)
def _per_read_length(buf, pos):
"""Вернуть (length, new_pos). Дословно per_read_length."""
c0 = buf[pos]
if c0 & 0x80:
return ((c0 << 8) + buf[pos + 1]) & 0x7FFF, pos + 2
return c0, pos + 1
def _gcc_conference_create_request(user_data):
"""GCC Conference Create Request (PER)."""
out = _per_choice(0) # Key: object (0)
out += _per_oid() # ITU-T T.124 OID (без тега, 6 байт)
out += _per_length(len(user_data) + 14) # connectPDU length
out += _per_choice(0) # ConnectGCCPDU: createRequest (0)
out += bytes([0x08]) # selection: userData present
out += bytes([0x00, 0x10]) # numeric "1" (длина 0 + BCD 10)
out += b"\x00" # padding (1)
out += bytes([1]) # number_of_sets = 1
out += _per_choice(0xC0) # value present + h221NonStandard
out += b"\x00" # padding перед ключом
out += _H221_CS_KEY # "Duca" (4, без длины)
out += _per_length(len(user_data)) # userData OCTET length
out += user_data
return out
def _client_core_data(width=1024, height=768, bpp=32, username="",
hostname="botmod", compat="modern", selected="rdp"):
"""TS_UD_CS_CORE (MS-RDPBCGR §2.2.1.3.2).
modern = RDP 10.7, legacy = RDP 5.0 (для Win XP/2003).
Минимум кодеков — сервер шлёт Bitmap.
"""
username_u = (username or "")[:15].encode("utf-16-le")
username_u += b"\x00" * (30 - len(username_u))
host_u = (hostname or "botmod")[:15].encode("utf-16-le")
host_u += b"\x00" * (32 - len(host_u))
color = {8: (0xCA01, 8), 16: (0xCA03, 16), 24: (0xCA04, 24),
32: (0xCA04, 32)}.get(bpp, (0xCA04, 32))
if compat == "legacy":
# XP: 8bit цвет, RDP 5.0.
width = max(640, min(1600, width))
height = max(480, min(1200, height))
color = (0xCA01, 8)
bpp = 8
# Конкатенация (struct с юникодом сложнее читать).
version = 0x00080004 if compat == "legacy" else 0x0008000C
core = struct.pack("<LHH", version, width, height)
core += struct.pack("<HH", color[0], 0xAA03) # colorDepth, SAS
core += struct.pack("<L", 0x409) # keyboardLayout (US)
build = 0x0E98 if compat == "legacy" else 0x47BB
core += struct.pack("<L", build) # clientBuild
# clientName — 32 байта UTF-16 (15 символов + NUL).
cname = (username or "botmod")[:15].encode("utf-16-le")
cname += b"\x00" * (32 - len(cname))
core = core[:20] + cname[:32]
core += struct.pack("<L", 4) # keyboardType (enhanced 101/102)
core += struct.pack("<L", 0) # keyboardSubType
core += struct.pack("<L", 12) # keyboardFunctionKey
core += b"\x00" * 64 # imeFileName
core += struct.pack("<H", color[0]) # postBeta2ColorDepth
core += struct.pack("<H", 1) # clientProductId
core += struct.pack("<L", 0) # serialNumber
if compat == "legacy":
core += struct.pack("<H", 0x0001) # highColorDepth (только 8)
core += struct.pack("<H", 0x0001) # supportedColorDepths (8)
core += struct.pack("<H", 0x0001) # earlyCapabilityFlags (RNS_UD
# _CS_NET? нет — 0x0001 = RNS_UD_COLOR_8BPP, без WAN-флагов)
else:
core += struct.pack("<H", 0x0018) # highColorDepth (24 + WANT_32)
core += struct.pack("<H", 0x000F) # supportedColorDepths (все)
core += struct.pack("<H", 0x05E3) # earlyCapabilityFlags
core += b"\x00" * 64 # clientDigProductId
conn_type = 0 if compat == "legacy" else 7
core += struct.pack("<B", conn_type) # connectionType
core += b"\x00" # pad1octet
# serverSelectedProtocol — эхо согласованного: rdp=0, tls=1, nla=2,
# nla-ext=8. Несовпадение рвёт Connect Initial.
ssp = {"rdp": 0, "tls": 1, "nla": 2, "nla-ext": 8}.get(selected, 0)
core += struct.pack("<L", ssp) # serverSelectedProtocol
core += struct.pack("<L", 0) # desktopPhysicalWidth (0=игнор)
core += struct.pack("<L", 0) # desktopPhysicalHeight
core += struct.pack("<H", 0) # desktopOrientation
core += struct.pack("<L", 0) # desktopScaleFactor
core += struct.pack("<L", 0) # deviceScaleFactor
# Фикс длины CORE до 234 (как FreeRDP gcc_write_client_core_data).
want = 234 - 4
if len(core) < want:
core += b"\x00" * (want - len(core))
core = core[:want]
# TS_UD header: type 0xC001 (CS_CORE), len.
return struct.pack("<HH", 0xC001, len(core) + 4) + core
def _client_security_data(compat="modern"):
"""TS_UD_CS_SEC (§2.2.1.3.3).
modern: encryptionMethods=40|128bit. legacy (XP): только 40bit
(0x01) — XP без 128-битного ключа иначе падает на Security Exchange.
"""
methods = 0x00000001 if compat == "legacy" else 0x00000003
body = struct.pack("<LL", methods, 0) # extEncryption=0
return struct.pack("<HH", 0xC002, len(body) + 4) + body
def _client_net_data():
"""TS_UD_CS_NET (§2.2.1.3.4): 1 канал — cliprdr (буфер обмена).
CHANNEL_DEF: имя 8 байт ASCII с NUL + options LE32.
options = INITIALIZED|ENCRYPT_RDP|COMPRESS_RDP|SHOW_PROTOCOL
(0xC0A00000 — байт-в-байт FreeRDP cliprdr_VirtualChannelEntryEx;
значения из wtsapi.h: 0x80000000|0x40000000|0x00800000|0x00200000).
Без INITIALIZED сервер канал не поднимет.
Порядок дефов = порядок id в SC_NET ответа (cliprdr = channels[0]).
"""
body = struct.pack("<L", 1) # channelCount
body += b"cliprdr\x00" + struct.pack("<L", 0xC0A00000)
return struct.pack("<HH", 0xC003, len(body) + 4) + body
def _client_cluster_data():
"""TS_UD_CS_CLUSTER (§2.2.1.3.5): flags=0."""
body = struct.pack("<LL", 0, 0)
return struct.pack("<HH", 0xC004, len(body) + 4) + body
def _domain_params_blob():
"""DomainParameters ×3 + upwardFlag — дословно из дампа FreeRDP.
xrdp капризен к BER-нюансам (minimum/maximum отличаются от target:
30 19 / 30 20 с другими значениями), поэтому не собираем руками,
а берём проверенные байты: upwardFlag(01 01 FF) + 3 SEQUENCE.
"""
return bytes.fromhex(
"0101ff"
"301a020122020102020100020101020100020101020300ffff020102"
"301902010102010102010102010102010002010102020420020102"
"3020020300ffff020300fc17020300ffff020101020100020101020300ffff020102")
def build_connect_initial(username="", width=1024, height=768, bpp=32,
compat="modern", selected="rdp"):
"""MCS Connect-Initial байты (класть в X.224 Data PDU).
MCS-обёртка BER (T.125 §11.1), домены — дословно из дампа FreeRDP
(проверены против xrdp), GCC — PER с пересчётом длин. Блоки свои:
CORE (подмена width/height/username), CLUSTER, SEC, NET.
compat: "modern" (RDP 10.7, 40|128bit) или "legacy" (RDP 5.0,
только 40bit, color 8bit — для Win XP/2003 и старых терминалов).
selected: согласованный security ('rdp'|'tls'|'nla'|'nla-ext') —
уходит эхом в CORE.serverSelectedProtocol.
"""
blocks = (_client_core_data(width, height, bpp, username,
compat=compat, selected=selected)
+ _client_cluster_data()
+ _client_security_data(compat)
+ _client_net_data())
gcc = _gcc_conference_create_request(blocks)
body = (_oct(b"\x01") + _oct(b"\x01") # calling/calledDomainSelector
+ _domain_params_blob()
+ _oct(gcc))
return _app(101, body)
def parse_connect_response(payload):
"""MCS Connect Response -> server_data.
Разбор: APP(7F 66) -> result(0A 01 ..) -> calledId(02 01 ..) ->
domainParams(30 ..) -> userData OCTET(04 ..) -> GCC Create Response
(choice, nodeID, tag, result, sets, McDn, OCTET(blocks)) -> блоки
SC_CORE(0x0C01)/SC_NET(0x0C03)/SC_SEC(0x0C02, сертификат).
Возвращает {width, height, io_channel, channels, enc_method,
enc_level, server_random, cert}.
"""
out = {"width": 800, "height": 600, "io_channel": 1003,
"channels": [], "enc_method": 0, "enc_level": 0,
"server_random": b"", "cert": b""}
try:
blocks = _resp_blocks(payload)
except Exception: # noqa: BLE001
return out
for typ, data in blocks:
try:
if typ == 0x0C01 and len(data) >= 8:
# SC_CORE ответа: version(4), requestedProtocols(4), early(4).
pass
elif typ == 0x0C03 and len(data) >= 4:
# SC_NET: MCSChannelId(2), channelCount(2), channelId*(2).
n = struct.unpack("<H", data[2:4])[0]
out["io_channel"] = struct.unpack("<H", data[0:2])[0]
out["channels"] = [
struct.unpack("<H", data[4 + i * 2:6 + i * 2])[0]
for i in range(min(n, 31))
if 4 + i * 2 + 2 <= len(data)]
elif typ == 0x0C02 and len(data) >= 16:
out["enc_method"] = struct.unpack("<L", data[0:4])[0]
out["enc_level"] = struct.unpack("<L", data[4:8])[0]
rln = struct.unpack("<L", data[8:12])[0]
cln = struct.unpack("<L", data[12:16])[0]
if rln == 32 and 16 + rln + cln <= len(data) + 1024:
out["server_random"] = data[16:16 + rln]
out["cert"] = data[16 + rln:16 + rln + cln]
except Exception: # noqa: BLE001
continue
return out
def _resp_blocks(payload):
"""[(type, data)] из MCS Connect Response. Бросает RdpError."""
from .tpkt import RdpError
if len(payload) < 5 or payload[0] != 0x7F:
raise RdpError("не Connect Response")
# APP len.
b0 = payload[2]
if b0 < 128:
pos = 3
else:
n = b0 & 0x7F
pos = 3 + n
# result INTEGER (0A 01 ..), calledId (02 01 ..), domainParams (30 ..).
for tag in (0x0A, 0x02, 0x30):
if pos >= len(payload) or payload[pos] != tag:
raise RdpError("плохой Connect Response (tag %02x)" % (
payload[pos] if pos < len(payload) else -1))
if tag == 0x30:
ln = payload[pos + 1]
pos += 2 + ln
else:
ln = payload[pos + 1]
pos += 2 + ln
# userData OCTET STRING (04 ..).
if pos >= len(payload) or payload[pos] != 0x04:
raise RdpError("нет userData в Connect Response")
b1 = payload[pos + 1]
if b1 < 128:
udln, pos = b1, pos + 2
else:
n = b1 & 0x7F
udln = int.from_bytes(payload[pos + 2:pos + 2 + n], "big")
pos = pos + 2 + n
gcc = payload[pos:pos + udln]
i = gcc.find(b"McDn")
if i < 0:
raise RdpError("нет McDn в Connect Response")
base = i + 4
# OCTET после McDn: per длина (1 байт или UINT16_BE|0x8000).
bln, base = _per_read_length(gcc, base)
blk = gcc[base:base + bln]
if len(blk) < bln:
raise RdpError("обрезанные блоки Connect Response")
out = []
p = 0
while p + 4 <= len(blk):
typ, ln = struct.unpack("<HH", blk[p:p + 4])
if ln < 4 or p + ln > len(blk) + 4:
break
out.append((typ, blk[p + 4:p + ln]))
p += ln
return out
# --- X.224 Data PDU обёртка ---
def x224_data(payload):
"""X.224 Data: LI=2 + 0xF0 + DST-REF + весь MCS в одном TPKT."""
return bytes([2, 0xF0, 0x80]) + payload
def write_mcs(conn, payload):
"""Отправить MCS PDU одним TPKT (как FreeRDP)."""
if len(payload) + 7 > 65535:
from .tpkt import RdpError
raise RdpError("MCS PDU слишком большая: %d" % len(payload))
conn.write(x224_data(payload))
def strip_x224_data(payload):
"""Снять X.224 Data шапку. Вернуть MCS payload."""
if len(payload) < 3 or payload[1] != 0xF0:
raise RdpError("плохой X.224 Data: %s" % payload[:4].hex())
return payload[3:]
def app_len(mcs):
"""Заявленная длина [APPLICATION 101] (7F 65 ...) или None."""
if len(mcs) < 4 or mcs[0] != 0x7F:
return None
b0 = mcs[2]
if b0 < 128:
return b0 + 3
n = b0 & 0x7F
if n > 2 or len(mcs) < 3 + n:
return None
return int.from_bytes(mcs[3:3 + n], "big") + 3 + n
# --- MCS DomainMCSPDU (PER, MS-RDPBCGR §2.2.1.5-9, сверено с дампом
# FreeRDP и кодом xrdp xrdp_mcs.c) ---
#
# ErectDomainRequest (5 байт): 04 [subHeight 2] [subInterval 2].
# opcode 04: (04>>2)=1=MCS_EDRQ. Дословно из дампа: 04 01 00 01 00.
# AttachUserRequest (1 байт): 28 — (0x28>>2)=10=MCS_AURQ, nonStandard=0.
# AttachUserConfirm: 2E result initiator-BE16 (xrdp: (11<<2)|2, result, uid).
# ChannelJoinRequest: 38 user-BE16 channel-BE16 ((0x38>>2)=14=MCS_CJRQ).
# ChannelJoinConfirm: 3E ... (читаем мягко, без строгого парсинга).
# SendDataRequest: 64 len initiator-BE16 channel-BE16 70 len data.
def erect_domain():
return bytes.fromhex("0401000100")
def attach_user():
return bytes([0x28])
def parse_attach_confirm(payload):
"""AttachUserConfirm -> user channel id (BE16)."""
from .tpkt import RdpError
if len(payload) >= 4 and payload[0] == 0x2E and payload[1] == 0:
return struct.unpack(">H", payload[2:4])[0]
raise RdpError("плохой Attach Confirm: %s" % payload[:8].hex())
def channel_join(user_id, channel_id):
return (bytes([0x38]) + struct.pack(">HH", user_id, channel_id))
def send_data(user_id, channel_id, data, priority=3):
"""MCS SendDataRequest (байт-в-байт по дампу FreeRDP + парсеру xrdp).
64 | initiator(2, xrdp игнорит) | channel BE16 | 70 (priority) |
per_len | data. per_len: <128 — 1 байт, иначе 0x80|hi + lo (14 бит).
ВНИМАНИЕ: длины после 0x64 НЕТ (была — xrdp читал всё со сдвигом!).
"""
n = len(data)
if n < 128:
ll = bytes([n])
elif n < 16384:
ll = bytes([0x80 | (n >> 8), n & 0xFF])
else:
from .tpkt import RdpError
raise RdpError("SendData слишком большой: %d" % n)
return (bytes([0x64]) + struct.pack(">H", user_id)
+ struct.pack(">H", channel_id) + bytes([0x70]) + ll + data)
def _ber_len(n):
if n < 128:
return bytes([n])
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(b)]) + b
rdp/npfast.py 2.5 КБ
"""remotedesk.rdp.npfast — опциональное numpy-ускорение planar-декода.
Всё внутри модуля: numpy декларируется в modules/remotedesk/requirements.txt
(ставится штатным pip-проходом install.sh по всем requirements.txt),
системных пакетов не требует. Без numpy — чистый Python-фолбэк в orders.py
(побайтово тот же результат; HAS_NUMPY=False).
Ускоряем только векторное: YCoCg->RGB (~19x: 2.7мс -> 0.14мс на 64x64)
и chroma-expand (~3x). RLE-плоскости остаются Python — там последовательные
зигзаг-дельты от предыдущей строки, векторизация не окупается.
raw32 и так на C-срезах (0.02мс) — numpy не быстрее.
"""
try:
import numpy as _np
HAS_NUMPY = True
except Exception: # noqa: BLE001
_np = None
HAS_NUMPY = False
def ycocg_to_rgb(Y, Co, Cg, n, cll):
"""YCoCg-плоскости -> bytes RGB888 (байт-в-байт _ycocg_to_rgb).
Y/Co/Cg — bytes-like длины n. Возвращает bytes(n*3).
Без numpy — RdpError? Нет: вызывающий падает в Python-фолбэк,
здесь кидаем RuntimeError чтобы orders.py свернул на него.
"""
if not HAS_NUMPY:
raise RuntimeError("numpy нет")
sh = cll - 1
y = _np.frombuffer(Y, dtype=_np.uint8).astype(_np.int16)
co = _np.frombuffer(Co, dtype=_np.uint8).astype(_np.int16)
cg = _np.frombuffer(Cg, dtype=_np.uint8).astype(_np.int16)
# convert(raw) = INT8(raw << (cll-1)) — та же формула что в Python.
co = (co << sh) & 0xFF
co = _np.where(co >= 128, co - 256, co)
cg = (cg << sh) & 0xFF
cg = _np.where(cg >= 128, cg - 256, cg)
t = y - cg
r = _np.clip(t - co, 0, 255).astype(_np.uint8)
g = _np.clip(y + cg, 0, 255).astype(_np.uint8)
b = _np.clip(t + co, 0, 255).astype(_np.uint8)
out = _np.empty((n, 3), dtype=_np.uint8)
out[:, 0] = r
out[:, 1] = g
out[:, 2] = b
return out.tobytes()
def subsample_expand(plane, pw, ph, w, h):
"""Chroma 2x nearest (байт-в-байт _subsample_expand)."""
if not HAS_NUMPY:
raise RuntimeError("numpy нет")
arr = _np.frombuffer(plane, dtype=_np.uint8).reshape(ph, pw)
return _np.repeat(_np.repeat(arr, 2, axis=0), 2, axis=1)[:h, :w] \
.tobytes()
rdp/orders.py 29.2 КБ
"""remotedesk.rdp.orders — Bitmap Updates, orders-заливки DSTBLT/RECT, ввод.
Пиксели 16-bit R5G6B5 / 32-bit XRGB; кодеки не рекламируем.
"""
import struct
from .tpkt import RdpError
def parse_update_pdu(data, bpp=32, palette=None):
"""TS_UPDATE_PDU: updateType 1=bitmap -> rects, 6=orders -> заливки.
Возвращает [(x, y, w, h, rgb888)].
"""
if len(data) < 6:
raise RdpError("короткий update pdu")
utype = struct.unpack("<H", data[:2])[0]
if utype == 1:
return parse_bitmap_update(data, bpp, palette)
if utype != 6:
raise RdpError("не bitmap/orders update: %d" % utype)
return _parse_orders(data[4:], bpp) # updateType(2)+pad(2), затем orders
def _parse_orders(body, bpp):
"""TS_UPDATE_ORDERS_PDU: numOrders + orders. Только заливки."""
import struct as _s
if len(body) < 2:
raise RdpError("короткий orders pdu")
(n,) = _s.unpack("<H", body[:2])
pos = 2
out = []
for _ in range(n):
if pos + 2 > len(body):
break
ctrl, otype = body[pos], body[pos + 1]
pos += 2
if ctrl & 0x40: # TS_SECONDARY (кеши) — пропуск по extraLen
if pos + 6 > len(body):
break
elen = _s.unpack("<H", body[pos + 4:pos + 6])[0]
pos += 6 + elen
continue
# Primary order (MS-RDPEGDI 2.2.2.2.1): controlFlags + orderType + [bounds] + fieldFlags + данные.
clip = None
if (ctrl & 0x04) and not (ctrl & 0x20):
# Bounds: left,top,right,bottom u16 (без DELTA_COORDINATES).
if pos + 8 > len(body):
break
import struct as _s2
if ctrl & 0x10:
break # delta-bounds не поддерживаем
clip = _s2.unpack("<HHHH", body[pos:pos + 8])
pos += 8
# fieldFlags для DSTBLT/RECT/PATBLT — 1 байт.
if pos + 1 > len(body):
break
field_flags = body[pos]
pos += 1
rect, color = None, (0, 0, 0)
if otype == 0: # DSTBLT: x,y,w,h(2×4) + bRop(1)
vals = _order_fields(field_flags, body, pos, [2, 2, 2, 2, 1])
if vals is None:
break
(x, y, w, h, _rop), pos = vals
# field_flags==0 — рисуем bounds.
if field_flags == 0 and clip is not None:
l, t, r, b = clip
x, y, w, h = l, t, max(0, r - l), max(0, b - t)
rect = (x, y, w, h)
elif otype in (1, 10): # PATBLT/OPAQUE_RECT: x,y,w,h + fgColor
nb = 3 if bpp <= 16 else 4
vals = _order_fields(field_flags, body, pos, [2, 2, 2, 2, nb])
if vals is None:
break
(x, y, w, h, fg), pos = vals
if field_flags == 0 and clip is not None:
l, t, r, b = clip
x, y, w, h = l, t, max(0, r - l), max(0, b - t)
else:
rect = (x, y, w, h)
color = _fg_to_rgb(fg, bpp)
rect = (x, y, w, h)
else:
# Неизвестный order: длина неизвестна — выходим без разрыва сессии.
break
if rect is not None:
x, y, w, h = rect
# Кап памяти от враждебного сервера.
if 0 < w <= 4096 and 0 < h <= 4096 and w * h <= 4 * 1048576:
rgb = bytes(color) * (w * h)
out.append((x, y, w, h, rgb))
return out
def _order_fields(field_flags, body, pos, sizes):
"""Поля order по битам fieldFlags: бит N=1 — поле присутствует. Возвращает (значения, new_pos)."""
import struct as _s
vals = []
for bit, sz in enumerate(sizes):
present = bool(field_flags & (1 << bit))
if present:
if pos + sz > len(body):
return None
vals.append(body[pos:pos + sz])
pos += sz
else:
vals.append(b"\x00" * sz)
nums = []
for v in vals:
if len(v) == 1:
nums.append(v[0])
elif len(v) == 2:
nums.append(_s.unpack("<H", v)[0])
elif len(v) == 3:
nums.append(v[0] | (v[1] << 8) | (v[2] << 16))
else:
nums.append(_s.unpack("<L", v[:4])[0])
return nums, pos
def _fg_to_rgb(fg, bpp):
if bpp <= 16:
v = fg & 0xFFFF
return (((v >> 11) & 31) * 255 // 31,
((v >> 5) & 63) * 255 // 63,
(v & 31) * 255 // 31)
# 32-bit XRGB LE: R в старшем байте.
return ((fg >> 16) & 0xFF, (fg >> 8) & 0xFF, fg & 0xFF)
def parse_palette(data):
"""TS_UPDATE_PALETTE_DATA: numColors + палитра RGB15. Возвращает [(r,g,b)]."""
if len(data) < 4:
raise RdpError("короткий palette update")
(n,) = struct.unpack("<H", data[2:4])
n = min(n, 256)
if len(data) < 4 + n * 2:
raise RdpError("обрезанная палитра")
pal = []
for i in range(n):
v = struct.unpack("<H", data[4 + i * 2:6 + i * 2])[0]
pal.append((((v >> 10) & 31) * 255 // 31,
((v >> 5) & 31) * 255 // 31,
(v & 31) * 255 // 31))
while len(pal) < 256:
pal.append((0, 0, 0))
return pal
def parse_bitmap_update(data, bpp=32, palette=None):
"""TS_BITMAP_DATA -> [(x, y, w, h, rgb888)].
flags бит 0: 0=raw, 1=RLE; строки bottom-up -> флип в top-down.
"""
if len(data) < 4:
raise RdpError("короткий bitmap update")
utype, count = struct.unpack("<HH", data[:4])
if utype != 1:
raise RdpError("не bitmap update: %d" % utype)
if count > 256:
raise RdpError("слишком много bitmap: %d" % count)
pos = 4
out = []
for _ in range(count):
if pos + 18 > len(data):
raise RdpError("обрезанный bitmap header")
dl, dt, dr, db, w, h, cbpp, flags, ln = struct.unpack(
"<HHHHHHHHH", data[pos:pos + 18])
pos += 18
# Кап памяти от враждебного сервера.
if w > 4096 or h > 4096 or w * h > 4 * 1048576:
raise RdpError("плохой bitmap %dx%d" % (w, h))
if pos + ln > len(data):
raise RdpError("обрезанный bitmap body")
body = data[pos:pos + ln]
pos += ln
rgb = _decode_bitmap(body, w, h, cbpp or bpp,
bool(flags & 1), palette)
if h > 1:
rs = w * 3
rgb = b"".join(
rgb[(h - 1 - r) * rs:(h - r) * rs] for r in range(h))
out.append((dl, dt, w, h, rgb))
return out
def _decode_bitmap(body, w, h, bpp, compressed, palette=None):
"""Bitmap в RGB888: raw / planar / interleaved RLE.
Planar пробуем первым при bpp==32 (строгая структурная валидация).
"""
if not compressed:
return _raw_to_rgb(body, w, h, bpp, palette)
import struct as _s
stream = body
if len(body) >= 8:
_first, main, scan, _uncomp = _s.unpack("<HHHH", body[:8])
_scan_bpp = 2 if bpp == 16 else 4 if bpp == 32 else 1 if (
bpp == 8) else 3
expect_scan = w * _scan_bpp
if (_first == 0 and 0 < main <= len(body) - 8
and scan == expect_scan):
stream = body[8:8 + main]
if bpp == 32 and stream:
try:
return _planar_decode(stream, w, h)
except RdpError:
pass
if not stream:
raise RdpError("пустой rle-поток")
bsize = 2 if bpp == 16 else 4 if bpp == 32 else 1 if bpp == 8 else 3
pix = _interleaved_rle_decode(stream, w, h, bsize)
out = bytearray(w * h * 3)
if bpp == 32:
for off in range(0, len(pix), 262144):
chunk = pix[off:off + 262144]
raw = struct.pack("<%dL" % len(chunk), *chunk)
base = off * 3
out[base:base + len(chunk) * 3:3] = raw[2::4]
out[base + 1:base + len(chunk) * 3:3] = raw[1::4]
out[base + 2:base + len(chunk) * 3:3] = raw[0::4]
return bytes(out)
for i, p in enumerate(pix):
if bpp == 16:
r = ((p >> 11) & 31) * 255 // 31
g = ((p >> 5) & 63) * 255 // 63
b = (p & 31) * 255 // 31
elif bpp == 32:
r, g, b = (p >> 16) & 255, (p >> 8) & 255, p & 255
elif bpp == 8:
if isinstance(palette, (list, tuple)) and 0 <= p < len(palette):
r, g, b = palette[p]
else: # grayscale fallback без палитры
r = g = b = p & 0xFF
else: # 24-bit LE: B,G,R
r, g, b = p[2], p[1], p[0] if isinstance(p, bytes) else (
(p >> 16) & 255, (p >> 8) & 255, p & 255)
out[i * 3:i * 3 + 3] = bytes((r, g, b))
return bytes(out)
def _interleaved_rle_decode(stream, w, h, bsize):
"""Interleaved RLE (MS-RDPBCGR §3.1.9): буфер bottom-up, флип делает вызывающий.
fg стартует белым; fInsertFgPel и FGBG-чанки по 8.
"""
n = w * h
pix = [0] * n
fg = (1 << (bsize * 8)) - 1 # начальный foreground = белый
pos = 0
idx = 0
insert_fg = False
def _u8():
nonlocal pos
if pos >= len(stream):
raise RdpError("обрезанный rle-поток")
v = stream[pos]
pos += 1
return v
def _u16():
return _u8() | (_u8() << 8)
def _px(count):
nonlocal pos
if pos + count * bsize > len(stream):
raise RdpError("обрезанные rle-пиксели")
out = []
for _ in range(count):
out.append(int.from_bytes(stream[pos:pos + bsize], "little"))
pos += bsize
return out
def _put(vals):
nonlocal idx
for v in vals:
if idx < n:
pix[idx] = v
idx += 1
def _above(i):
# Первая строка и незаписанные — 0.
j = i - w
return pix[j] if 0 <= j < len(pix) else 0
def _fgbg(run, first):
# Маска run бит (LSB first); ран через границу строк не делится.
nonlocal pos
nbytes = (run + 7) // 8
if pos + nbytes > len(stream):
raise RdpError("обрезанная fgbg-маска")
mask = stream[pos:pos + nbytes]
pos += nbytes
vals = []
for k in range(run):
bit = (mask[k // 8] >> (k % 8)) & 1
if first:
vals.append(fg if bit else 0)
else:
base = _above(idx + k)
vals.append((base ^ fg) if bit else base)
_put(vals)
while idx < n:
if pos >= len(stream):
# Обрезанный поток — ошибка.
raise RdpError("обрезанный rle-поток")
hdr = _u8()
# ExtractCodeId: regular/mega+special/lite.
if (hdr & 0xC0) != 0xC0:
code = hdr >> 5
is_mega = False
elif (hdr & 0xF0) == 0xF0:
code = hdr
is_mega = True
else:
code = hdr >> 4
is_mega = False
first_line = idx < w
if code in (0x00, 0xF0): # REGULAR_BG_RUN / MEGA_MEGA_BG_RUN
if is_mega:
run = _u16()
else:
run = hdr & 0x1F
if run == 0:
run = _u8() + 32
if insert_fg:
# FG-пиксель между back-to-back BG-ранами.
_put([_above(idx) ^ fg])
run -= 1
if first_line:
_put([0] * run)
else:
_put([_above(idx + k) for k in range(run)])
insert_fg = True
continue
insert_fg = False
if code in (0x01, 0xF1): # FG_RUN
if is_mega:
run = _u16()
else:
run = hdr & 0x1F
if run == 0:
run = _u8() + 32
if first_line:
_put([fg] * run)
else:
_put([_above(idx + k) ^ fg for k in range(run)])
elif code in (0x02, 0xF2): # FGBG_IMAGE
if is_mega:
run = _u16()
else:
# Нулевой ран: следующий байт +1 как есть, иначе ран*8.
run = hdr & 0x1F
if run == 0:
run = _u8() + 1
else:
run *= 8
_fgbg(run, first_line)
elif code in (0x03, 0xF3): # COLOR_RUN
if is_mega:
run = _u16()
else:
run = hdr & 0x1F
if run == 0:
run = _u8() + 32
(c,) = _px(1)
_put([c] * run)
elif code in (0x04, 0xF4): # COLOR_IMAGE
if is_mega:
run = _u16()
else:
run = hdr & 0x1F
if run == 0:
run = _u8() + 32
_put(_px(run))
elif code in (0x0C, 0xF6): # SET_FG_RUN
if is_mega:
run = _u16()
else:
run = hdr & 0x0F
if run == 0:
run = _u8() + 16
fg = _px(1)[0]
if first_line:
_put([fg] * run)
else:
_put([_above(idx + k) ^ fg for k in range(run)])
elif code in (0x0D, 0xF7): # SET_FGBG_IMAGE
if is_mega:
run = _u16()
else:
# Нулевой ран: next+1 без *8.
run = hdr & 0x0F
if run == 0:
run = _u8() + 1
else:
run *= 8
fg = _px(1)[0]
_fgbg(run, first_line)
elif code in (0x0E, 0xF8): # DITHERED_RUN
if is_mega:
run = _u16()
else:
run = hdr & 0x0F
if run == 0:
run = _u8() + 16
c1, c2 = _px(2)
# run — число пар, пикселей 2*run.
vals = []
for _ in range(run):
vals += [c1, c2]
_put(vals)
elif code == 0xF9: # SPECIAL_FGBG_1 (маска 0x03, 8 px)
_put([(_above(idx + k) ^ fg) if (0x03 >> k) & 1
else _above(idx + k) for k in range(8)])
elif code == 0xFA: # SPECIAL_FGBG_2 (маска 0x05, 8 px)
_put([(_above(idx + k) ^ fg) if (0x05 >> k) & 1
else _above(idx + k) for k in range(8)])
elif code == 0xFD: # WHITE (1 белый пиксель)
_put([0xFFFFFF & ((1 << (bsize * 8)) - 1)])
elif code == 0xFE: # BLACK (1 чёрный пиксель)
_put([0])
else:
raise RdpError("неизвестный rle-код: %02x" % hdr)
return pix
def _planar_decode(body, w, h):
"""RDP 6.0 planar: FormatHeader + плоскости [A,]Y,Co,Cg.
CLL==0: RGB; CLL>0: YCoCg; строгая валидация размеров.
"""
if len(body) < 1:
raise RdpError("короткий planar bitmap")
header = body[0]
cll = header & 0x07
cs = bool(header & 0x08)
use_rle = bool(header & 0x10)
has_alpha = not (header & 0x20)
if cll == 0 and cs:
raise RdpError("planar: CS без CLL")
if cll > 0 and not cs and w * h * 3 > 0:
pass # YCoCg без сабсемплинга — допустимо
pos = 1
planes = []
if has_alpha:
aplane, pos = _plane_exact(body, pos, w, h, use_rle)
planes.append(aplane)
yplane, pos = _plane_exact(body, pos, w, h, use_rle)
if cs:
cw, ch = (w + 1) // 2, (h + 1) // 2
coplane, pos = _plane_exact(body, pos, cw, ch, use_rle)
cgplane, pos = _plane_exact(body, pos, cw, ch, use_rle)
try:
from . import npfast as _npf
coplane = _npf.subsample_expand(coplane, cw, ch, w, h)
cgplane = _npf.subsample_expand(cgplane, cw, ch, w, h)
except Exception: # noqa: BLE001
coplane = _subsample_expand(coplane, cw, ch, w, h)
cgplane = _subsample_expand(cgplane, cw, ch, w, h)
else:
coplane, pos = _plane_exact(body, pos, w, h, use_rle)
cgplane, pos = _plane_exact(body, pos, w, h, use_rle)
if pos != len(body):
# Одиночный trailing pad-байт допустим.
if pos + 1 == len(body) and body[pos] == 0:
pos += 1
else:
raise RdpError("planar: лишние данные")
if has_alpha:
planes.pop(0) # alpha не используем (XRGB)
out = bytearray(w * h * 3)
if cll == 0:
# RGB: плоскости Y=R, Co=G, Cg=B.
out[0::3] = yplane
out[1::3] = coplane
out[2::3] = cgplane
else:
try:
from . import npfast as _npf2
return bytes(_npf2.ycocg_to_rgb(
yplane, coplane, cgplane, w * h, cll))
except Exception: # noqa: BLE001
pass
_ycocg_planes_to_rgb(out, yplane, coplane, cgplane, w * h, cll)
return bytes(out)
def _ycocg_planes_to_rgb(out, Y, Co, Cg, n, cll):
"""YCoCg-плоскости -> RGB888 в out."""
sh = cll - 1
o = 0
for i in range(n):
co = (Co[i] << sh) & 0xFF
if co >= 128:
co -= 256
cg = (Cg[i] << sh) & 0xFF
if cg >= 128:
cg -= 256
y = Y[i]
t = y - cg
r = t - co
g = y + cg
b = t + co
if r < 0:
r = 0
elif r > 255:
r = 255
if g < 0:
g = 0
elif g > 255:
g = 255
if b < 0:
b = 0
elif b > 255:
b = 255
out[o] = r
out[o + 1] = g
out[o + 2] = b
o += 3
def _plane_exact(body, pos, w, h, use_rle):
"""Декодировать плоскость w*h с ТОЧНЫМ потреблением. Иначе RdpError."""
need = w * h
if not use_rle:
if pos + need > len(body):
raise RdpError("короткая raw-плоскость")
return body[pos:pos + need], pos + need
plane, end = _rle_plane_decode(body, pos, w, h, True)
if len(plane) != need or end > len(body):
raise RdpError("плохая rle-плоскость")
return plane, end
def _subsample_expand(plane, pw, ph, w, h):
"""Chroma 2x nearest: даблап строк/столбцов; край дублируется."""
rows = []
for sy in range(ph):
src = plane[sy * pw:sy * pw + pw]
er = bytearray(w)
er[0::2] = src[:(w + 1) // 2]
er[1::2] = src[:w // 2]
er = bytes(er)
rows.append(er)
if len(rows) < h:
rows.append(er) # даблап строки (y=2*sy и y=2*sy+1)
if len(rows) >= h:
break
return b"".join(rows[:h])
def _ycocg_to_rgb(Y, Co, Cg, cll):
"""YCoCg->RGB: T=Y-Cg; R=T-Co; G=Y+Cg; B=T+Co."""
sh = cll - 1
co = ((Co << sh) & 0xFF)
co = co - 256 if co >= 128 else co
cg = ((Cg << sh) & 0xFF)
cg = cg - 256 if cg >= 128 else cg
T = Y - cg
R, G, B = T - co, Y + cg, T + co
return (max(0, min(255, R)), max(0, min(255, G)),
max(0, min(255, B)))
def _rle_plane_decode(body, pos, w, h, use_rle):
"""RLE-плоскость w*h: control byte (младший ниббл run, старший raw); run 1/2 — расширения.
Первая строка — абсолютные значения, далее zigzag-дельты.
"""
need = w * h
if not use_rle:
if pos + need > len(body):
raise RdpError("короткая raw-плоскость")
return body[pos:pos + need], pos + need
out = bytearray(need)
p = pos
for y in range(h):
x = 0
pixel = 0 # последний raw-пиксель (знаковый для дельта-строк)
base = y * w
first = (y == 0)
while x < w:
if p >= len(body):
raise RdpError("обрезанная rle-плоскость")
ctrl = body[p]
p += 1
run = ctrl & 0x0F
raw = (ctrl >> 4) & 0x0F
if run == 1:
run = raw + 16
raw = 0
elif run == 2:
run = raw + 32
raw = 0
if x + raw + run > w:
raise RdpError("переполнение rle-строки")
if first:
if raw:
if p + raw > len(body):
raise RdpError("обрезанные rle-literals")
out[base + x:base + x + raw] = body[p:p + raw]
pixel = body[p + raw - 1]
p += raw
x += raw
if run:
out[base + x:base + x + run] = bytes(
(pixel & 0xFF,)) * run
x += run
else:
for _ in range(raw):
if p >= len(body):
raise RdpError("обрезанные rle-literals")
d = body[p]
p += 1
if d & 1:
pixel = -((d >> 1) + 1)
else:
pixel = d >> 1
out[base + x] = (out[base - w + x] + pixel) & 0xFF
x += 1
for _ in range(run):
out[base + x] = (out[base - w + x] + pixel) & 0xFF
x += 1
return bytes(out), p
def _raw_to_rgb(body, w, h, bpp, palette=None):
need = w * h * (2 if bpp == 16 else 4 if bpp == 32 else 3
if bpp == 24 else 1)
if len(body) < need:
raise RdpError("короткий raw bitmap")
out = bytearray(w * h * 3)
if bpp == 8:
# 8-bit: индекс палитры, без неё — grayscale.
pal = palette or ([(i, i, i) for i in range(256)])
for i in range(w * h):
out[i * 3:i * 3 + 3] = bytes(pal[body[i] % len(pal)])
elif bpp == 16:
for i in range(w * h):
p = struct.unpack_from("<H", body, i * 2)[0]
out[i * 3:i * 3 + 3] = bytes((
((p >> 11) & 31) * 255 // 31,
((p >> 5) & 63) * 255 // 63,
(p & 31) * 255 // 31))
elif bpp == 32:
# XRGB LE: байты B,G,R,X.
out = bytearray(w * h * 3)
out[0::3] = body[2::4]
out[1::3] = body[1::4]
out[2::3] = body[0::4]
return bytes(out)
else: # 24-bit
for i in range(w * h):
out[i * 3:i * 3 + 3] = body[i * 3:i * 3 + 3]
return bytes(out)
# --- Ввод: slow-path (MS-RDPBCGR §2.2.8.1.1.3.1.1.1 / §2.2.8.1.1.3.1.1.3) ---
def _data_pdu(share_id, pdu_type2, payload):
"""Share Data Header + payload.
uncompressedLength = len(payload); pduSource = io_ch.
"""
sdh = struct.pack("<LBBHBBH", share_id, 0, 1,
len(payload), pdu_type2, 0, 0)
inner = sdh + payload
sch = struct.pack("<HHH", 6 + len(inner), 0x10 | 7, 0x03EB)
return sch + inner
def client_synchronize(share_id, target_user=0x03EA):
"""TS_SYNCHRONIZE_PDU (pduType2=31): messageType(2)=1 + targetUser(2)."""
return _data_pdu(share_id, 31, struct.pack("<HH", 1, target_user))
def client_control(share_id, action, grant_id=0, control_id=0):
"""TS_CONTROL_PDU (pduType2=20): 1=Request, 2=Granted, 4=Cooperate."""
return _data_pdu(share_id, 20,
struct.pack("<HHHH", action, grant_id, control_id, 0))
def client_font_list(share_id):
"""TS_FONT_LIST_PDU (pduType2=39): ListFlags=0x0003 (FIRST|LAST)."""
return _data_pdu(share_id, 39, struct.pack("<HHHH", 0, 0, 0x0003, 50))
def _ev12(msg_type, flags, p1, p2):
"""TS_INPUT_EVENT 12B: eventTime(4)=0 + messageType(2) +
deviceFlags(2) + param1(2) + param2(2)."""
return struct.pack("<LHHhH", 0, msg_type, flags, p1, p2)
def slowpath_sync(toggle_flags=0):
"""TS_SYNC_EVENT: messageType=0 (RDP_INPUT_SYNCHRONIZE)."""
return _ev12(0, toggle_flags, 0, 0)
def slowpath_key(scancode, down=True, extended=False):
"""TS_KEYBOARD_EVENT (messageType=4): press=DOWN(0x4000), release=RELEASE(0x8000)."""
flags = 0x4000 if down else 0x0000 # KBDFLAGS_DOWN — только press
if not down:
flags |= 0x8000 # KBDFLAGS_RELEASE
if extended:
flags |= 0x0100 # KBDFLAGS_EXTENDED
return _ev12(4, flags, scancode, 0)
def slowpath_unicode(codepoint, release=False):
"""TS_UNICODE_KEYBOARD_EVENT (messageType=5): press флагами 0, release=0x8000."""
flags = 0x8000 if release else 0x0000
return struct.pack("<LHH", 0, 5, flags) + struct.pack(
"<H", int(codepoint) & 0xFFFF) + b"\x00\x00"
def parse_pointer_update(code, data):
"""Fast-Path pointer updates: 8=pos, 5/6=system, 9/10/11=смена курсора. Неизвестное — {}."""
try:
if code == 8: # TS_FP_POINTERPOSATTRIBUTE: x(2)+y(2)
if len(data) < 4:
return {}
x, y = struct.unpack("<HH", data[:4])
return {"pos": [x, y]}
if code == 5: # TS_FP_SYSTEMPOINTERHIDDENATTRIBUTE
return {"system": "hidden"}
if code == 6: # TS_FP_SYSTEMPOINTERDEFAULTATTRIBUTE
return {"system": "default"}
if code == 10: # TS_FP_CACHEDPOINTERATTRIBUTE: cacheIndex(2)
if len(data) < 2:
return {}
return {"cached": struct.unpack("<H", data[:2])[0]}
if code in (9, 11): # COLOR / NEW
return {"ptr": True}
except Exception: # noqa: BLE001
return {}
return {}
def client_input(share_id, *events):
"""TS_INPUT_PDU (pduType2=28): numEvents + events."""
body = struct.pack("<HH", len(events), 0) + b"".join(events)
return _data_pdu(share_id, 28, body)
def slowpath_mouse(x, y, buttons=0, wheel=0, down=True):
"""TS_POINTER_EVENT (0x8001): buttons 1L/2R/4M; без кнопок — MOVE; wheel — ротация во флагах."""
flags = 0
if wheel:
rot = 0x78 if wheel > 0 else 0x88
flags = 0x0200 | rot # PTRFLAGS_WHEEL | rotation
if wheel < 0:
flags |= 0x0100 # PTRFLAGS_WHEEL_NEGATIVE
return _ev12(0x8001, flags, x, y)
if buttons & 1:
flags |= 0x1000 # PTRFLAGS_BUTTON1
if buttons & 2:
flags |= 0x2000 # PTRFLAGS_BUTTON2 (правая)
if buttons & 4:
flags |= 0x4000 # PTRFLAGS_BUTTON3
if flags:
if down:
flags |= 0x8000 # PTRFLAGS_DOWN
else:
flags |= 0x0800 # PTRFLAGS_MOVE (движение без кнопок)
return _ev12(0x8001, flags, x, y)
#: DOM code -> (scancode, extended). Покрывает 120+ клавиш.
DOM_TO_SCANCODE = {
"Escape": (0x01, 0), "Digit1": (0x02, 0), "Digit2": (0x03, 0),
"Digit3": (0x04, 0), "Digit4": (0x05, 0), "Digit5": (0x06, 0),
"Digit6": (0x07, 0), "Digit7": (0x08, 0), "Digit8": (0x09, 0),
"Digit9": (0x0A, 0), "Digit0": (0x0B, 0), "Minus": (0x0C, 0),
"Equal": (0x0D, 0), "Backspace": (0x0E, 0), "Tab": (0x0F, 0),
"KeyQ": (0x10, 0), "KeyW": (0x11, 0), "KeyE": (0x12, 0),
"KeyR": (0x13, 0), "KeyT": (0x14, 0), "KeyY": (0x15, 0),
"KeyU": (0x16, 0), "KeyI": (0x17, 0), "KeyO": (0x18, 0),
"KeyP": (0x19, 0), "BracketLeft": (0x1A, 0), "BracketRight": (0x1B, 0),
"Enter": (0x1C, 0), "ControlLeft": (0x1D, 0), "KeyA": (0x1E, 0),
"KeyS": (0x1F, 0), "KeyD": (0x20, 0), "KeyF": (0x21, 0),
"KeyG": (0x22, 0), "KeyH": (0x23, 0), "KeyJ": (0x24, 0),
"KeyK": (0x25, 0), "KeyL": (0x26, 0), "Semicolon": (0x27, 0),
"Quote": (0x28, 0), "Backquote": (0x29, 0), "ShiftLeft": (0x2A, 0),
"Backslash": (0x2B, 0), "KeyZ": (0x2C, 0), "KeyX": (0x2D, 0),
"KeyC": (0x2E, 0), "KeyV": (0x2F, 0), "KeyB": (0x30, 0),
"KeyN": (0x31, 0), "KeyM": (0x32, 0), "Comma": (0x33, 0),
"Period": (0x34, 0), "Slash": (0x35, 0), "ShiftRight": (0x36, 0),
"NumpadMultiply": (0x37, 0), "AltLeft": (0x38, 0), "Space": (0x39, 0),
"CapsLock": (0x3A, 0),
"F1": (0x3B, 0), "F2": (0x3C, 0), "F3": (0x3D, 0), "F4": (0x3E, 0),
"F5": (0x3F, 0), "F6": (0x40, 0), "F7": (0x41, 0), "F8": (0x42, 0),
"F9": (0x43, 0), "F10": (0x44, 0), "NumLock": (0x45, 0),
"ScrollLock": (0x46, 0),
"Numpad7": (0x47, 0), "Numpad8": (0x48, 0), "Numpad9": (0x49, 0),
"NumpadSubtract": (0x4A, 0), "Numpad4": (0x4B, 0),
"Numpad5": (0x4C, 0), "Numpad6": (0x4D, 0), "NumpadAdd": (0x4E, 0),
"Numpad1": (0x4F, 0), "Numpad2": (0x50, 0), "Numpad3": (0x51, 0),
"Numpad0": (0x52, 0), "NumpadDecimal": (0x53, 0),
"F11": (0x57, 0), "F12": (0x58, 0),
"NumpadEnter": (0x1C, 1), "ControlRight": (0x1D, 1),
"NumpadDivide": (0x35, 1), "AltRight": (0x38, 1),
"Home": (0x47, 1), "ArrowUp": (0x48, 1), "PageUp": (0x49, 1),
"ArrowLeft": (0x4B, 1), "ArrowRight": (0x4D, 1), "End": (0x4F, 1),
"ArrowDown": (0x50, 1), "PageDown": (0x51, 1), "Insert": (0x52, 1),
"Delete": (0x53, 1), "MetaLeft": (0x5B, 1), "MetaRight": (0x5C, 1),
"ContextMenu": (0x5D, 1),
"PrintScreen": (0x37, 1), "Pause": (0x45, 0),
}
rdp/security.py 25.4 КБ
"""remotedesk.rdp.security — RDP Security: rdp / tls / nla.
NTLMv2 руками, SPNEGO-DER через struct.
"""
import hashlib
import hmac as _hmac
import logging
import os
import struct
logger = logging.getLogger(__name__)
# --- NTLMv2 (MS-NLMP §2.2.2, известные формулы) ---
NTLMSSP_SIG = b"NTLMSSP\x00"
def ntlm_hash(password):
"""NTOWFv2: MD4(UTF-16LE(password)). Нужен pycryptodome (MD4)."""
try:
from Crypto.Hash import MD4 as _MD4
except ImportError:
raise ValueError("нет pycryptodome (MD4 для NTLM)")
h = _MD4.new()
h.update((password or "").encode("utf-16-le"))
return h.digest()
def _av_pairs_parse(ti: bytes):
"""Разобрать AV_PAIRS в [(type, value)]."""
out = []
p = 0
while p + 4 <= len(ti):
t, ln = struct.unpack("<HH", ti[p:p + 4])
v = ti[p + 4:p + 4 + ln]
if len(v) < ln:
break
out.append((t, v))
p += 4 + ln
if t == 0:
break
return out
def _av_pairs_build(pairs):
out = b""
for t, v in pairs:
out += struct.pack("<HH", t, len(v)) + bytes(v)
return out
def _server_timestamp(server_ti: bytes):
"""AV7 (MsvAvTimestamp) из серверного TI или None."""
for t, v in _av_pairs_parse(bytes(server_ti)):
if t == 7 and len(v) == 8:
return struct.unpack("<Q", v)[0]
if t == 0:
break
return None
def _client_target_info(server_ti: bytes, timestamp_u64: int, spn_host: str = ""):
"""TI для NTLMv2-блоба: серверный + свой timestamp + FLAGS/MIC + BINDINGS + SPN."""
pairs = [(t, v) for t, v in _av_pairs_parse(bytes(server_ti))
if t != 0]
ts = struct.pack("<Q", timestamp_u64)
pairs = [(t, (ts if t == 7 else v)) for t, v in pairs]
if not any(t == 7 for t, _ in pairs):
pairs.append((7, ts))
pairs.append((6, struct.pack("<L", 2))) # MSV_AV_FLAGS | MIC
pairs.append((10, b"\x00" * 16)) # MsvAvChannelBindings (пустые)
if spn_host:
pairs.append((9, ("TERMSRV/%s" % spn_host).encode("utf-16-le")))
pairs.append((0, b""))
return _av_pairs_build(pairs)
def ntlmv2_response(nt_hash, server_challenge, client_challenge,
timestamp, target_info, username, domain,
spn_host=""):
"""NTLMv2 Response: blob без хвостовых нулей, TI клиентский; вернуть (proof, blob, lm, sess_key)."""
blob_sig = b"\x01\x01\x00\x00"
# Proof покрывает клиентский TI из блоба.
ti = _client_target_info(target_info, timestamp, spn_host)
blob = (blob_sig + b"\x00" * 4 + struct.pack("<Q", timestamp)
+ client_challenge + b"\x00" * 4 + ti)
mac = _hmac.new(
_ntlm_response_key(nt_hash, username, domain),
server_challenge + blob, hashlib.md5).digest()
lm_proof = _hmac.new(
_ntlm_response_key(nt_hash, username, domain),
server_challenge + client_challenge, hashlib.md5).digest()
lm_resp = lm_proof[:16] + client_challenge
session_base_key = _hmac.new(
_ntlm_response_key(nt_hash, username, domain), mac,
hashlib.md5).digest()
return mac, blob, lm_resp, session_base_key
def _ntlm_response_key(nt_hash, username, domain):
"""ResponseKeyNT (NTOWFv2): HMAC-MD5(NT-hash, UPPER(user)+dom UTF-16LE)."""
return _hmac.new(nt_hash, (username.upper() + domain).encode(
"utf-16-le"), hashlib.md5).digest()
def ntlm_negotiate(domain="", workstation=""):
"""Type 1 (NEGOTIATE_MESSAGE), флаги 0xE20882B7."""
flags = 0xE20882B7
dom = (domain or "").encode("ascii", errors="ignore")
ws = (workstation or "").encode("ascii", errors="ignore")
# Пустые домен/воркстейшн не сериализуем (Type1 = 40 байт).
hdr = struct.pack("<8sL", NTLMSSP_SIG, 1)
hdr += struct.pack("<LHH", flags, len(dom), len(dom))
off = 0
if dom or ws:
# offsets относительно начала (40 = 12 + 8 + 8 + 4 + 8 с Version).
off = 40
hdr += struct.pack("<L", off)
hdr += struct.pack("<HH", len(ws), len(ws)) + struct.pack(
"<L", (off + len(dom)) if (dom or ws) else 0)
# Version (8 байт).
hdr += struct.pack("<BBH", 6, 3, 9600) + b"\x00\x00\x00" + bytes([15])
return hdr + dom + ws
def _rc4(key, data):
"""RC4 (pycryptodome, свежий контекст на вызов — для session key)."""
from Crypto.Cipher import ARC4 as _ARC4
return _ARC4.new(bytes(key)).encrypt(bytes(data))
def ntlm_authenticate(server_challenge_msg, username, password, domain="",
workstation="", negotiate_msg=b"", spn_host=""):
"""Разобрать Type 2, вернуть (Type 3, client_nonce, RandomSessionKey).
MIC сразу за заголовком (offset 72); nonce един на сессию.
"""
if server_challenge_msg[8:12] != struct.pack("<L", 2):
raise ValueError("не NTLM Type 2")
# TargetInfo из Type 2.
t_len, _, t_off = struct.unpack("<HHL", server_challenge_msg[40:48])
target_info = server_challenge_msg[t_off:t_off + t_len]
srv_chal = server_challenge_msg[24:32]
cli_chal = os.urandom(8)
import time as _t
# Timestamp блоба — эхо серверного AV7; нет AV7 — своё время.
ts = _server_timestamp(target_info)
if ts is None:
ts = int((_t.time() + 11644473600) * 10000000) # FILETIME
nt_hash = ntlm_hash(password or "")
proof, blob, lm_resp, sess_key = ntlmv2_response(
nt_hash, srv_chal, cli_chal, ts,
target_info, username or "", domain or "", spn_host)
nt_resp = proof + blob
# RandomSessionKey -> RC4(KeyExchangeKey=sess_key) -> на провод.
rand_sess = os.urandom(16)
enc_sess = _rc4(sess_key, rand_sess)
dom = (domain or "").encode("utf-16-le")
user = (username or "").encode("utf-16-le")
try:
local_host = (workstation or __import__("socket").gethostname()
or "BOTMOD")
except Exception: # noqa: BLE001
local_host = workstation or "BOTMOD"
ws = local_host.encode("utf-16-le")[:64]
base = 8 + 4 + 8 * 6 + 4 + 8 # 72
mic_off = base # MIC сразу за заголовком (как xfreerdp)
user_off = mic_off + 16
ws_off = user_off + len(user)
lm_off = ws_off + len(ws)
nt_off = lm_off + len(lm_resp)
dom_off = nt_off + len(nt_resp)
sk_off = dom_off + len(dom)
hdr = struct.pack("<8sL", NTLMSSP_SIG, 3)
hdr += struct.pack("<HHL", len(lm_resp), len(lm_resp), lm_off)
hdr += struct.pack("<HHL", len(nt_resp), len(nt_resp), nt_off)
hdr += struct.pack("<HHL", len(dom), len(dom), dom_off)
hdr += struct.pack("<HHL", len(user), len(user), user_off)
hdr += struct.pack("<HHL", len(ws), len(ws), ws_off)
hdr += struct.pack("<HHL", len(enc_sess), len(enc_sess), sk_off)
# Флаги — ровно как пишет xfreerdp в Type3 (0xE288A235).
flags = 0xE288A235
hdr += struct.pack("<L", flags)
hdr += struct.pack("<BBH", 6, 3, 9600) + b"\x00\x00\x00" + bytes([15])
t3 = hdr + b"\x00" * 16 + user + ws + lm_resp + nt_resp + dom + enc_sess
# Ключ MIC — RandomSessionKey, не SessionBaseKey.
mic = _hmac.new(rand_sess,
bytes(negotiate_msg) + bytes(server_challenge_msg)
+ t3, hashlib.md5).digest()
t3 = t3[:mic_off] + mic + t3[mic_off + 16:]
return t3, cli_chal, rand_sess
# --- SPNEGO (MS-SPNG + RFC 4178) ---
SPNEGO_OID_BODY = bytes.fromhex("2b0601050502") # 1.3.6.1.5.5.2
NTLMSSP_OID_BODY = bytes.fromhex("2b06010401823702020a") # 1.3.6.1.4.1.311.2.2.10
def _tlv(tag, content: bytes) -> bytes:
return bytes([tag]) + _der_len(len(content)) + content
def spnego_init(ntlm_type1):
"""NegTokenInit { mechTypes=[NTLMSSP], mechToken=Type1 } в DER."""
oid = _tlv(0x06, NTLMSSP_OID_BODY)
mech_types = _tlv(0xA0, _tlv(0x30, oid))
mech_tok = _tlv(0xA2, _tlv(0x04, bytes(ntlm_type1)))
neg_init = _tlv(0xA0, _tlv(0x30, mech_types + mech_tok))
# InitialContextToken [APPLICATION 0]: thisMech + NegTokenInit.
return _tlv(0x60, _tlv(0x06, SPNEGO_OID_BODY) + neg_init)
def spnego_resp(ntlm_type3):
"""NegTokenResp { responseToken: Type3 } в DER (голый A1{A2{04{Type3}}})."""
return _tlv(0xA1, _tlv(0xA2, _tlv(0x04, bytes(ntlm_type3))))
def spnego_init_wrapped(ntlm_type1, nonce=None):
return ts_request(nego_tokens=spnego_init(ntlm_type1),
client_nonce=nonce if nonce is not None
else os.urandom(32))
def spnego_resp_wrapped(ntlm_type3, nonce=None):
return ts_request(nego_tokens=spnego_resp(ntlm_type3),
client_nonce=nonce if nonce is not None
else os.urandom(32))
def _der_len(n):
if n < 128:
return bytes([n])
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(b)]) + b
def spnego_parse_challenge(ts_request):
"""Вытащить responseToken (NTLM Type2) из TSRequest. Вернуть bytes."""
# Ищем NTLMSSP-сигнатуру внутри DER.
i = ts_request.find(NTLMSSP_SIG)
if i < 0:
raise ValueError("в CredSSP-ответе нет NTLM-токена")
# Type 2: читаем до конца токена эвристикой (TargetInfo len).
# Берём остаток — сервер не шлёт ничего после токена в этой фазе.
return ts_request[i:]
# --- CredSSP TSRequest/TSResponse (MS-CSSP §2.2.1.1) ---
def ts_request(version=6, nego_tokens=b"", auth_info=b"", pub_key_auth=b"",
client_nonce=b""):
"""TSRequest SEQUENCE: negoTokens [1] = A1{30{30{A0{04{token}}}}}; nonce — тег [5]."""
out = _tlv(0xA0, _der_int(2, version))
if nego_tokens:
octs = _der_oct(bytes(nego_tokens))
nego_data = _tlv(0xA0, octs)
nego_data = _tlv(0x30, nego_data)
nego_data = _tlv(0x30, nego_data)
out += _tlv(0xA1, nego_data)
if auth_info:
out += _tlv(0xA2, _der_oct(bytes(auth_info)))
if pub_key_auth:
out += _tlv(0xA3, _der_oct(bytes(pub_key_auth)))
if client_nonce:
out += _tlv(0xA5, _der_oct(bytes(client_nonce)))
return bytes([0x30]) + _der_len(len(out)) + out
def _der_int(tag, n):
b = n.to_bytes((n.bit_length() + 7) // 8 or 1, "big")
if b[0] & 0x80:
b = b"\x00" + b
return bytes([tag]) + _der_len(len(b)) + b
def _der_oct(data):
return bytes([0x04]) + _der_len(len(data)) + data
def _der_ctx(n, data):
"""Контекстный конструктный тег [n] вокруг 02/04."""
inner = bytes(data)
if inner[0] not in (0x02, 0x04):
raise ValueError("контекстный тег только вокруг 02/04")
return bytes([0xA0 | n]) + _der_len(len(inner)) + inner
def ts_password_creds(domain, username, password):
"""TSPasswordCreds в DER: A0{02{1}} + A0/A1/A2 вокруг OCTET строк."""
du = domain.encode("utf-16-le")
uu = (username or "").encode("utf-16-le")
pu = (password or "").encode("utf-16-le")
inner = (_der_ctx(0, _der_oct(du)) + _der_ctx(1, _der_oct(uu))
+ _der_ctx(2, _der_oct(pu)))
seq = bytes([0x30]) + _der_len(len(inner)) + inner
# TSCredentials { credType [0]=1 (password), credentials [1] OCTET(SEQ) }.
octs = _der_oct(seq)
cred = (_der_ctx(0, _der_int(2, 1)) + bytes([0xA1])
+ _der_len(len(octs)) + octs)
return bytes([0x30]) + _der_len(len(cred)) + cred
def credssp_handshake(conn, username, password, domain="",
early_auth=False, spn_host=""):
"""Полный CredSSP поверх TLS. Раннее auth — проверка reject в resp2; на authInfo ответа нет."""
from . import tpkt as _t
import os as _os
nonce = _os.urandom(32)
nego1 = ntlm_negotiate(domain)
conn.write_credssp(ts_request(nego_tokens=nego1,
client_nonce=nonce))
resp1 = conn.read_credssp()
type2 = _spnego_find_ntlm(resp1)
t3, _cli, rand_sess = ntlm_authenticate(
type2, username, password, domain,
negotiate_msg=nego1, spn_host=spn_host or _spn_host(conn))
conn._ntlm_sess_key = rand_sess
# pubKeyAuth: SHA256(magic + nonce + subjectPublicKey) + seal.
try:
der = conn.tls.getpeercert(binary_form=True) if conn.tls else b""
except Exception: # noqa: BLE001
der = b""
spki = _spki_from_der(der)
pub_hash = _pubkey_hash(nonce, spki)
seal_key, sign_key, rc4 = _ntlm_seal_keys(rand_sess)
enc_hash, sig = _ntlm_seal(seal_key, sign_key, rc4, 0, pub_hash)
conn._ntlm_seal = [seal_key, sign_key, rc4, 1]
conn.write_credssp(ts_request(nego_tokens=t3, pub_key_auth=sig + enc_hash,
client_nonce=nonce))
logger.info("remotedesk rdp: msg2 sent (%d)", len(t3) + 48)
resp2 = conn.read_credssp()
logger.info("remotedesk rdp: resp2 ok (%d)", len(bytes(resp2)))
# Сервер должен вернуть accept — проверяем грубо.
if b"\x0a\x01\x00" in resp2: # reject
raise ValueError("CredSSP: сервер отклонил учётку")
# TSPasswordCreds — NTLM-seal, seq=1 на продолжении RC4-потока.
creds = ts_password_creds(domain, username, password)
enc_creds, sig_c = _ntlm_seal(seal_key, sign_key, rc4, 1, creds)
conn._ntlm_seal = [seal_key, sign_key, rc4, 2]
conn.write_credssp(ts_request(auth_info=sig_c + enc_creds,
client_nonce=nonce))
logger.info("remotedesk rdp: msg3 sent")
return True
# --- CredSSP pubKeyAuth + authInfo (MS-CSSP, по winpr nla.c) ---
_CLIENT_SERVER_MAGIC = (b"CredSSP Client-To-Server Binding Hash\x00")
_SERVER_CLIENT_MAGIC = (b"CredSSP Server-To-Client Binding Hash\x00")
def _spki_from_der(der):
"""Содержимое BIT STRING subjectPublicKey из DER (без тега/длин/0x00)."""
if not der or der[0] != 0x30:
raise ValueError("пустой DER-сертификат")
import struct as _s
ln = der[1]
j = 2
if ln & 0x80:
n = ln & 0x7F
j += n
# tbsCertificate.
if der[j] != 0x30:
raise ValueError("не x509")
ln = der[j + 1]
k = j + 2
if ln & 0x80:
n = ln & 0x7F
k += n
tbs_end = k + (ln if not (der[j + 1] & 0x80) else
int.from_bytes(der[j + 2:j + 2 + n], "big"))
# Внутри tbs пропускаем 6 TLV до SubjectPublicKeyInfo.
p = k
for _ in range(6):
p = _tlv_skip(der, p)
# p -> subjectPublicKeyInfo SEQUENCE.
if der[p] != 0x30:
raise ValueError("нет SPKI")
ln = der[p + 1]
q = p + 2
if ln & 0x80:
n = ln & 0x7F
ln = int.from_bytes(der[q:q + n], "big")
q += n
spki_end = q + ln
# Внутри SPKI нужен только контент BIT STRING.
r = q
r = _tlv_skip(der, r) # alg
if der[r] != 0x03 or r >= spki_end:
raise ValueError("нет subjectPublicKey")
bln = der[r + 1]
s = r + 2
if bln & 0x80:
n = bln & 0x7F
s += n
if der[s] != 0x00:
raise ValueError("не 0 unused-bits")
return der[s + 1:spki_end]
def _tlv_skip(buf, p):
ln = buf[p + 1]
q = p + 2
if ln & 0x80:
n = ln & 0x7F
ln = int.from_bytes(buf[q:q + n], "big")
q += n
return q + ln
def _pubkey_hash(nonce32, spki_der):
"""SHA256(ClientServerMagic + nonce + голый subjectPublicKey)."""
import hashlib as _h
h = _h.sha256()
h.update(_CLIENT_SERVER_MAGIC)
h.update(bytes(nonce32))
h.update(bytes(spki_der))
return h.digest()
def _ntlm_seal_keys(exported_session_key):
"""(seal_key, sign_key, rc4) клиента C2S (winpr: ClientSealingKey,
ClientSigningKey, SendRc4Seal=RC4(ClientSealingKey))."""
import hashlib as _h
esk = bytes(exported_session_key)
seal = _h.md5(esk + b"session key to client-to-server "
b"sealing key magic constant\x00").digest()
sign = _h.md5(esk + b"session key to client-to-server "
b"signing key magic constant\x00").digest()
from Crypto.Cipher import ARC4 as _ARC4
return seal, sign, _ARC4.new(seal)
def _ntlm_seal(seal_key, sign_key, rc4, seqno, plaintext):
"""NTLM seal: RC4(plain) + подпись ver(4)=1 + RC4(HMAC(sign,seq+plain))[:8]
+ seqno(4). Вернуть (ciphertext, signature16). RC4-поток общий —
состояние продолжает вызывающий."""
import struct as _s
import hashlib as _h
import hmac as _hm
ct = rc4.encrypt(bytes(plaintext))
digest = _hm.new(bytes(sign_key),
_s.pack("<L", seqno) + bytes(plaintext),
_h.md5).digest()
checksum = rc4.encrypt(digest[:8])
sig = _s.pack("<L", 1) + checksum + _s.pack("<L", seqno)
return ct, sig
def _spn_host(conn):
"""Хост для SPN TERMSRV/host (адрес назначения соединения)."""
try:
peer = conn.sock.getpeername()
return str(peer[0]) if peer else ""
except Exception: # noqa: BLE001
pass
try:
peer = conn.sock.sock.getpeername()
return str(peer[0]) if peer else ""
except Exception: # noqa: BLE001
return ""
def _spnego_find_ntlm(ts_request_bytes):
"""Найти NTLMSSP-блоб внутри TSRequest (Type1/2/3 — по сигнатуре)."""
i = bytes(ts_request_bytes).find(NTLMSSP_SIG)
if i < 0:
raise ValueError("в TSRequest нет NTLM-токена")
return bytes(ts_request_bytes)[i:]
# --- Standard RDP Security: RC4 + MAC (MS-RDPBCGR §5.3) ---
def rdp_salt_session_key(client_random, server_random):
"""48-байт pre-master -> session key (первые 16 MD5). Упрощённо."""
return hashlib.md5(client_random + server_random).digest()
class _Rc4Stream:
"""Непрерывный RC4-поток одного направления; смена ключа каждые 4096 пакетов."""
def __init__(self, key16):
from Crypto.Cipher import ARC4 as _ARC4
self._arc4 = _ARC4.new(key16)
self.use_count = 0
def crypt(self, data):
out = self._arc4.encrypt(bytes(data))
self.use_count += 1
return out
def mac_data(key, data):
"""MAC: MD5(key + pad + data) первые 8 байт (MS-RDPBCGR §5.3.5)."""
pad = b"\x36" * 40
return hashlib.md5(key + pad + data).digest()[:8]
# --- Legacy (Standard RDP Security) ---
def parse_server_cert(cert):
"""Proprietary server certificate -> (n, exp, enc_len 64/256)."""
from .tpkt import RdpError
if len(cert) < 20:
raise RdpError("короткий сертификат")
# version(4) + sigAlg(4) + keyAlg(4) + blobType(2)=6 + blobLen(2).
blob = cert[16:]
if len(blob) < 20 or blob[0:4] != b"RSA1":
raise RdpError("не RSA1-сертификат: %s" % blob[0:4].hex())
keylen = struct.unpack("<L", blob[4:8])[0]
exp = struct.unpack("<L", blob[16:20])[0]
mod = blob[20:20 + keylen]
if len(mod) < keylen:
raise RdpError("обрезанный модуль RSA")
# Режем хвостовые LE-нули; enc_len 64/256.
mod = mod.rstrip(b"\x00")
if len(mod) <= 64:
enc_len = 64
elif len(mod) <= 256:
enc_len = 256
else:
raise RdpError("слишком большой модуль RSA: %d" % len(mod))
mod = mod.rjust(enc_len, b"\x00")
n = int.from_bytes(mod[:enc_len], "little")
return n, exp, enc_len
def rsa_encrypt(n, exp, data, keylen):
"""Сырой RSA без паддинга: LE-число в степень exp."""
if len(data) > keylen:
raise ValueError("RSA-данные длиннее ключа")
m = int.from_bytes(data, "little")
c = pow(m, exp, n)
out = c.to_bytes(keylen, "little")
if len(out) < keylen:
out += b"\x00" * (keylen - len(out))
return out[:keylen]
def _hash_48(out48_in, salt1_32, salt2_32, salt_byte):
"""3x (SHA1 -> MD5)."""
out = b""
for i in range(3):
pad = bytes([salt_byte + i]) * (i + 1)
sha = hashlib.sha1(pad + out48_in + salt1_32 + salt2_32).digest()
out += hashlib.md5(out48_in + sha).digest()
return out
def _hash_16(in16, salt1_32, salt2_32):
return hashlib.md5(in16 + salt1_32 + salt2_32).digest()
class RdpCrypto:
"""Session keys legacy RDP: c2s/s2s из sess[32:48]/sess[16:32]; роль — наша точка зрения."""
def __init__(self, client_random_32, server_random_32, method=2,
role="client"):
cr, sr = client_random_32, server_random_32
inp = cr[:24] + sr[:24]
tmp = _hash_48(inp, cr, sr, 65)
sess = _hash_48(tmp, cr, sr, 88)
self.sign_key = sess[:16]
s2c = _hash_16(sess[16:32], cr, sr) # server->client
c2s = _hash_16(sess[32:48], cr, sr) # client->server
if role == "client":
self.encrypt_key, self.decrypt_key = c2s, s2c
else:
self.encrypt_key, self.decrypt_key = s2c, c2s
if method == 1: # 40-bit: усечение (xrdp_sec_make_40bit)
self.sign_key = b"\xd1\x26\x9e" + self.sign_key[3:8] + b"\x00" * 8
self.encrypt_key = b"\xd1\x26\x9e" + self.encrypt_key[3:8] + b"\x00" * 8
self.decrypt_key = b"\xd1\x26\x9e" + self.decrypt_key[3:8] + b"\x00" * 8
self.key_len = 8
else:
self.key_len = 16
self._enc_stream = _Rc4Stream(self.encrypt_key[:self.key_len])
self._dec_stream = _Rc4Stream(self.decrypt_key[:self.key_len])
self.enc_count = 0
self.dec_count = 0
def sign(self, data):
"""SHA1+MD5 подпись."""
lh = struct.pack("<L", len(data))
sha = hashlib.sha1(self.sign_key[:self.key_len] + b"\x36" * 40
+ lh + data).digest()
md5 = hashlib.md5(self.sign_key[:self.key_len] + b"\x5c" * 48
+ sha).digest()
return md5[:8]
def encrypt(self, data):
out = self._enc_stream.crypt(data)
self.enc_count += 1
return out
def decrypt(self, data):
out = self._dec_stream.crypt(data)
self.dec_count += 1
return out
def build_security_exchange(cert, client_random_32):
"""Security Exchange PDU: flags(0x01) + len + RSA(client_random)."""
from .tpkt import RdpError
n, exp, keylen = parse_server_cert(cert)
enc = rsa_encrypt(n, exp, client_random_32, keylen)
# len = keylen + 8.
return struct.pack("<L", 0x01) + struct.pack("<L", len(enc) + 8) + enc
INFO_MOUSE = 0x0001
INFO_DISABLECTRLALTDEL = 0x0002
INFO_AUTOLOGON = 0x0008
INFO_UNICODE = 0x0010
INFO_MAXIMIZESHELL = 0x0020
INFO_ENABLEWINDOWSKEY = 0x0100
def build_client_info(username, password, domain="", program="",
directory=""):
"""TS_INFO_PACKET (Unicode): длины без NUL, в пакете каждая строка с NUL."""
du = (domain or "").encode("utf-16-le")
uu = (username or "").encode("utf-16-le")
pu = (password or "").encode("utf-16-le")
au = (program or "").encode("utf-16-le")
wd = (directory or "").encode("utf-16-le")
flags = (INFO_MOUSE | INFO_DISABLECTRLALTDEL | INFO_AUTOLOGON
| INFO_UNICODE | INFO_MAXIMIZESHELL | INFO_ENABLEWINDOWSKEY)
pkt = struct.pack("<L", 0) # CodePage (0 = Unicode)
pkt += struct.pack("<L", flags)
pkt += struct.pack("<HHHHH", len(du), len(uu), len(pu), len(au),
len(wd))
for s in (du, uu, pu, au, wd):
pkt += s + b"\x00\x00"
pkt += build_extended_info()
return pkt
def build_extended_info(client_address="127.0.0.1", client_dir="",
performance_flags=0x0008):
"""TS_EXTENDED_INFO_PACKET: cb-длины включают NUL."""
addr = client_address.encode("utf-16-le")
cdir = (client_dir or "").encode("utf-16-le")
out = struct.pack("<H", 2) # clientAddressFamily = AF_INET
out += struct.pack("<H", len(addr) + 2) + addr + b"\x00\x00"
out += struct.pack("<H", len(cdir) + 2) + cdir + b"\x00\x00"
out += b"\x00" * 172 # TS_TIME_ZONE_INFORMATION (нули = UTC)
out += struct.pack("<L", 0) # clientSessionId
out += struct.pack("<L", performance_flags)
return out
def wrap_encrypted(crypto, payload):
"""SEC_ENCRYPT|SEC_LOGON_INFO + sign + RC4 (Client Info)."""
flags = 0x0008 | 0x0040 | 0x0800
sig = crypto.sign(payload)
return struct.pack("<L", flags) + sig + crypto.encrypt(payload)
def wrap_share(crypto, payload):
"""Share/input PDU: SEC_ENCRYPT + sign + RC4 (флаги 0x0808, без 0x40)."""
flags = 0x0008 | 0x0800
sig = crypto.sign(payload)
return struct.pack("<L", flags) + sig + crypto.encrypt(payload)
def build_license_info():
"""CLIENT_LICENSE_INFO минимальный: preamble + dwVersion + пустые поля."""
body = struct.pack("<L", 2) # dwVersion
body += struct.pack("<L", 0) # cbCompanyName (пусто)
body += struct.pack("<L", 0) # cbProductId (пусто)
body += struct.pack("<L", 0) # cbLicenseInfo (пусто)
pre = bytes([0x12, 0x02]) + struct.pack("<H", len(body))
return pre + body
def wrap_license(crypto, payload):
"""License-пакет клиента: flags 0x0A88 + sign + RC4."""
flags = 0x0008 | 0x0080 | 0x0200 | 0x0800
sig = crypto.sign(payload)
return struct.pack("<L", flags) + sig + crypto.encrypt(payload)
rdp/tpkt.py 12.5 КБ
"""remotedesk.rdp.tpkt — TPKT + X.224 (MS-RDPBCGR §2.2.1.1-2.2.1.2, MS-CSSP)."""
import logging
import socket
import struct
logger = logging.getLogger(__name__)
# RDP Negotiation Request/Response (MS-RDPBCGR §2.2.1.1.1/§2.2.1.2.1).
RDP_NEG_REQ = 0x01
RDP_NEG_RSP = 0x02
RDP_NEG_FAILURE = 0x03
PROTOCOL_RDP = 0x00000000
PROTOCOL_SSL = 0x00000001
PROTOCOL_HYBRID = 0x00000002
PROTOCOL_RDSTLS = 0x00000004
PROTOCOL_HYBRID_EX = 0x00000008
NEG_FAILURE_NAMES = {
0x01: "SSL_REQUIRED_BY_SERVER",
0x02: "SSL_NOT_ALLOWED_BY_SERVER",
0x03: "SSL_CERT_NOT_ON_SERVER",
0x04: "INCONSISTENT_FLAGS",
0x05: "HYBRID_REQUIRED_BY_SERVER",
0x06: "SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER",
}
MAX_PDU = 65535
class RdpError(ValueError):
pass
class RdpTimeout(RdpError):
"""Таймаут чтения — не смерть: сервер молчит между апдейтами."""
class Conn:
"""TPKT-сокет с bounds-check. После STARTTLS оборачивается в SSL."""
def __init__(self, sock, timeout=10):
self.sock = sock
self.sock.settimeout(timeout)
self.buf = bytearray()
self.tls = None
def _fill(self, n):
while len(self.buf) < n:
try:
chunk = self.sock.recv(max(4096, n - len(self.buf)))
except socket.timeout:
raise RdpTimeout("таймаут чтения RDP")
except OSError as e:
raise RdpError("обрыв RDP: %s" % str(e)[:100])
if not chunk:
raise RdpError("сервер закрыл соединение")
self.buf += chunk
if len(self.buf) > 4 * 1048576:
raise RdpError("переполнение буфера RDP")
def read(self, n):
if n < 0 or n > 4 * 1048576:
raise RdpError("плохая длина чтения: %d" % n)
self._fill(n)
out = bytes(self.buf[:n])
del self.buf[:n]
return out
def read_tpkt(self):
"""Читать один TPKT-пакет, вернуть payload (после 4-байт шапки)."""
hdr = self.read(4)
ver, _, ln = struct.unpack(">BBH", hdr)
if ver != 3:
raise RdpError("плохой TPKT version: %d" % ver)
if ln < 4 or ln > MAX_PDU:
raise RdpError("плохая длина TPKT: %d" % ln)
return self.read(ln - 4)
def write(self, payload):
"""Завернуть payload в TPKT и отправить."""
if len(payload) + 4 > MAX_PDU:
raise RdpError("TPKT слишком большой")
hdr = struct.pack(">BBH", 3, 0, len(payload) + 4)
try:
self.sock.sendall(hdr + payload)
except OSError as e:
raise RdpError("запись RDP: %s" % str(e)[:100])
def write_raw(self, tpkt_bytes):
"""Отправить готовый TPKT-пакет (для сегментов X.224)."""
try:
self.sock.sendall(tpkt_bytes)
except OSError as e:
raise RdpError("запись RDP: %s" % str(e)[:100])
def read_tpkt_raw(self):
"""Читать один TPKT-пакет целиком (с шапкой)."""
hdr = self.read(4)
ver, _, ln = struct.unpack(">BBH", hdr)
if ver != 3:
raise RdpError("плохой TPKT version: %d" % ver)
if ln < 4 or ln > MAX_PDU:
raise RdpError("плохая длина TPKT: %d" % ln)
return hdr + self.read(ln - 4)
def read_pdu(self):
"""Читать один PDU любого типа. Вернуть ('slow', mcs_bytes) или
('fast', fp_bytes).
Slow-Path всегда начинается с TPKT (первый байт 0x03), Fast-Path
Output — с fpOutputHeader, у которого биты 0-1 = action 0, т.е.
первый байт НИКОГДА не 0x03 (MS-RDPBCGR §2.2.9.1.2, fastpath.c).
Fast-Path длина — PER: 1 байт (<0x80) или 2 байта, ВКЛЮЧАЯ шапку.
"""
b0 = self.read(1)
if b0 == b"\x03":
# Вернуть байт в буфер — дальше штатный TPKT-путь.
self.buf = bytearray(b"\x03") + self.buf
return "slow", self.read_mcs()
hdr = b0 + self.read(1)
b1 = hdr[1]
if b1 & 0x80:
hdr += self.read(1)
total = ((b1 & 0x7F) << 8) | hdr[2]
consumed = 3
else:
total = b1
consumed = 2
if total < consumed or total > MAX_PDU:
raise RdpError("плохая длина fast-path: %d" % total)
return "fast", hdr + self.read(total - consumed)
def read_mcs(self):
"""Читать MCS PDU: обычно один TPKT целиком (как шлёт FreeRDP).
Если BER-длина APP больше пришедшего (сервер порезал на TPKT),
дочитываем следующие TPKT и склеиваем (X.224-шапки снимаем).
"""
from . import mcs as _mcs
first = self.read_tpkt_raw()
if len(first) < 7:
raise RdpError("короткий X.224")
body = first[4:]
if body[1] not in (0xF0, 0x70):
raise RdpError("плохой X.224 тип: %02x" % body[1])
mcs = body[3:] if body[1] == 0xF0 else body[2:]
want = _mcs.app_len(mcs)
out = mcs
for _ in range(64):
if want is None or len(out) >= want:
return out
nxt = self.read_tpkt_raw()
if len(nxt) < 6:
raise RdpError("короткий X.224-продолжение")
nb = nxt[4:]
if nb[1] == 0xF0:
out += nb[3:]
elif nb[1] == 0x70:
out += nb[2:]
else:
raise RdpError("плохой X.224 тип: %02x" % nb[1])
raise RdpError("слишком много X.224-сегментов")
def start_tls(self, host=""):
"""Обернуть сокет в TLS (для tls/nla). Вернуть peer-cert DER."""
import ssl as _ssl
ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
try:
tls = ctx.wrap_socket(self.sock, server_hostname=host or None)
except Exception as e: # noqa: BLE001
raise RdpError("TLS handshake: %s" % str(e)[:150])
self.sock = tls
self.tls = tls
try:
return tls.getpeercert(binary_form=True)
except Exception: # noqa: BLE001
return b""
def write_credssp(self, data):
"""Отправить TSRequest напрямую в TLS-канал (БЕЗ TPKT).
CredSSP (MS-CSSP) идёт поверх TLS как raw application data,
без TPKT-шапки. MCS и всё после — через write() (TPKT).
"""
try:
self.sock.sendall(bytes(data))
except OSError as e:
raise RdpError("CredSSP запись: %s" % str(e)[:100])
def read_credssp(self, timeout_s=15):
"""Прочитать один TSRequest из TLS-канала (raw, по DER-длине).
Один recv может вернуть часть PDU или склейку — дочитываем
по DER-заголовку SEQUENCE (0x30 + длина), иначе NTLM Type2
обрежется и сервер потом уйдёт в alert.
"""
import socket as _s
self.sock.settimeout(timeout_s)
buf = b""
try:
while True:
try:
chunk = self.sock.recv(65536)
except _s.timeout:
raise RdpError("таймаут CredSSP")
if not chunk:
raise RdpError("CredSSP: сервер закрыл соединение")
buf += chunk
total = _der_total(buf)
if total is not None and len(buf) >= total:
return buf[:total]
if len(buf) > 256 * 1024:
raise RdpError("CredSSP: слишком большой ответ")
except RdpError:
raise
except OSError as e:
raise RdpError("CredSSP чтение: %s" % str(e)[:100])
def close(self):
try:
self.sock.close()
except Exception: # noqa: BLE001
pass
def _der_total(buf):
"""Полная длина DER SEQUENCE (0x30 + длина) или None (нехватает)."""
if len(buf) < 2 or buf[0] != 0x30:
return None
b1 = buf[1]
if b1 < 128:
return 2 + b1
n = b1 & 0x7F
if n == 0 or n > 4 or len(buf) < 2 + n:
return None
ln = int.from_bytes(buf[2:2 + n], "big")
return 2 + n + ln
def _x224_crq(requested_protocols, cookie=""):
"""X.224 Connection Request + cookie + RDP Negotiation Request."""
user = b""
if cookie:
# Cookie: "Cookie: mstshash=USERNAME\r\n" (MS-RDPBCGR §2.2.1.1).
user = b"Cookie: mstshash=" + cookie.encode(
"ascii", errors="ignore")[:64] + b"\r\n"
neg = struct.pack("<BBHI", RDP_NEG_REQ, 0, 8, requested_protocols)
# X.224 CR: LI(1) + code(1: 0xE0 CR) + DST(2) + SRC(2) + CLASS(1).
varlen = len(user) + len(neg)
li = 6 + varlen # длина после LI: code..opt + variable part
cr = struct.pack(">BBBBBH", li, 0xE0, 0, 0, 0, 0)
return cr + user + neg
def negotiate(host, port=3389, mode="any", username="", timeout=10,
cookie=""):
"""X.224 handshake + выбор security. Вернуть (Conn, selected).
selected: 'rdp'|'tls'|'nla'|'nla-ext'. mode: any|nla|nla-ext|tls|rdp.
"""
order = {"nla": [PROTOCOL_HYBRID | PROTOCOL_SSL],
"nla-ext": [PROTOCOL_HYBRID_EX | PROTOCOL_HYBRID
| PROTOCOL_SSL],
"tls": [PROTOCOL_SSL],
"rdp": [PROTOCOL_RDP],
"any": [PROTOCOL_HYBRID | PROTOCOL_SSL,
PROTOCOL_SSL, PROTOCOL_RDP]}.get(mode)
if order is None:
raise RdpError("плохой security mode: %s" % mode)
last_err = "нет ответа"
for want in order:
try:
return _try_negotiate(host, port, want, username, timeout,
cookie)
except RdpError as e:
last_err = str(e)
logger.info("remotedesk rdp: %08x -> %s", want, e)
hint = ""
if "HYBRID_REQUIRED_BY_SERVER" in last_err and mode in ("tls", "rdp"):
hint = " (сервер требует NLA: поставьте security=nla)"
raise RdpError("negotiation не удалась: %s%s" % (last_err[:200], hint))
def _try_negotiate(host, port, want, username, timeout, cookie):
try:
sock = socket.create_connection((host, int(port or 3389)),
timeout=timeout)
except Exception as e: # noqa: BLE001
raise RdpError("rdp %s: %s" % (host, str(e)[:150]))
c = Conn(sock, timeout)
cookie_val = username if username else (cookie or "")
c.write(_x224_crq(want, cookie_val))
try:
payload = c.read_tpkt()
except RdpError as e:
c.close()
raise RdpError("confirm: %s" % e)
# X.224 CC: LI + 0xD0 + DST + SRC + CLASS(0) [+ neg rsp/failure].
if len(payload) < 7 or payload[1] != 0xD0:
c.close()
raise RdpError("плохой X.224 Confirm: %s" % payload[:8].hex())
rest = payload[7:]
if len(rest) >= 8 and rest[0] == RDP_NEG_RSP:
sel = struct.unpack("<I", rest[4:8])[0]
if sel & PROTOCOL_HYBRID_EX:
return c, "nla-ext"
if sel & PROTOCOL_HYBRID:
return c, "nla"
if sel & PROTOCOL_RDSTLS:
c.close()
raise RdpError("сервер выбрал RDSTLS (не поддерживаем)")
if sel & PROTOCOL_SSL:
return c, "tls"
return c, "rdp"
if len(rest) >= 8 and rest[0] == RDP_NEG_FAILURE:
code = struct.unpack("<I", rest[4:8])[0]
c.close()
raise RdpError("сервер отказал: %s"
% NEG_FAILURE_NAMES.get(code, hex(code)))
# Нет neg-ответа: старый сервер, только Standard RDP Security.
if want != PROTOCOL_RDP:
c.close()
raise RdpError("сервер без negotiation (только legacy rdp?)")
return c, "rdp"
rfb/__init__.py 0.0 КБ
"""remotedesk.rfb package."""
rfb/client.py 4.9 КБ
"""remotedesk.rfb.client — VNC-клиент (поток чтения).
connect() -> VncSession; ввод pointer/key/cut потокобезопасен (lock).
"""
import logging
import threading
import time
logger = logging.getLogger(__name__)
class VncSession:
def __init__(self, conn, info, frame, zctx):
self.conn = conn
self.info = info
self.frame = frame
self.zctx = zctx
self.bpp = 4
self._stop = threading.Event()
self._lock = threading.Lock()
self._thread = None
self.last_update = time.time()
self.updates = 0
@property
def width(self):
return self.frame.width
@property
def height(self):
return self.frame.height
def start(self, fps=10):
import threading as _th
self._fps = max(1, min(15, int(fps or 10)))
self._thread = _th.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
try:
self.conn.close()
except Exception: # noqa: BLE001
pass
def _loop(self):
from . import encodings as _enc
from . import proto as _p
c = self.conn
try:
_p.request_update(c, 0, 0, self.frame.width,
self.frame.height, False)
while not self._stop.is_set():
try:
with self._lock:
t, msg = _p.read_message(c)
except _p.RfbError as e:
logger.info("remotedesk vnc: конец: %s", e)
break
if t == 0:
for _ in range(msg.get("n_rects", 0)):
try:
with self._lock:
x, y, w, h, enc = _p.read_rect_header(c)
except _p.RfbError as e:
logger.warning("remotedesk vnc header: %s", e)
break
if enc == _p.ENC_DESKTOPSIZE:
# Сервер сменил размер: пересоздать framebuffer.
try:
from . import frame as _frame
self.frame = _frame.Frame(w, h)
with self._lock:
_p.request_update(
c, 0, 0, w, h, False)
except ValueError as e:
logger.warning("remotedesk vnc resize: %s",
e)
continue
try:
with self._lock:
dirty = _enc.decode_rect(
c, self.frame.fb, self.frame.width,
self.frame.height, x, y, w, h, enc,
self.zctx, self.bpp)
except _p.RfbError as e:
logger.warning("remotedesk vnc rect: %s", e)
break
self.frame.apply(dirty)
self.last_update = time.time()
self.updates += 1
try:
with self._lock:
_p.request_update(c, 0, 0, self.frame.width,
self.frame.height, True)
except _p.RfbError:
break
elif t == 3:
logger.debug("remotedesk vnc cut: %r",
msg.get("text", "")[:64])
time.sleep(1.0 / self._fps)
except Exception as e: # noqa: BLE001
logger.info("remotedesk vnc loop: %s: %s",
type(e).__name__, str(e)[:150])
def pointer(self, x, y, buttons=0):
from . import proto as _p
x = max(0, min(int(x), self.frame.width - 1))
y = max(0, min(int(y), self.frame.height - 1))
with self._lock:
_p.send_pointer(self.conn, x, y, buttons)
def key(self, keysym, down=True):
from . import proto as _p
with self._lock:
_p.send_key(self.conn, int(keysym), down)
def cut(self, text):
from . import proto as _p
with self._lock:
_p.send_cut_text(self.conn, text)
def connect(host, port=5900, password="", timeout=10, shared=True,
fps=10):
"""Подключиться и запустить poll-поток. Вернуть VncSession."""
from . import encodings as _enc
from . import frame as _frame
from . import proto as _p
c, info = _p.handshake(host, port, password, timeout, shared)
fr = _frame.Frame(info["width"], info["height"])
sess = VncSession(c, info, fr, _enc.ZlibCtx())
sess.start(fps)
return sess
rfb/encodings.py 9.1 КБ
"""remotedesk.rfb.encodings — декодеры rects в RGB888 (RFC 6143 §7.7).
decode_rect -> dirty-rects; длины проверяются до чтения.
"""
import struct
import zlib
def _px(bpp_buf, off, size):
"""Пиксель 32-bit LE -> (r, g, b)."""
if size == 4:
v = struct.unpack_from("<L", bpp_buf, off)[0]
return ((v >> 16) & 255, (v >> 8) & 255, v & 255)
if size == 2:
v = struct.unpack_from("<H", bpp_buf, off)[0]
return (((v >> 11) & 31) * 255 // 31,
((v >> 5) & 63) * 255 // 63,
(v & 31) * 255 // 31)
return (bpp_buf[off], bpp_buf[off], bpp_buf[off])
def _blit(fb, stride_w, x, y, w, h, rgb):
r, g, b = rgb
base = (y * stride_w + x) * 3
row = bytes((r, g, b)) * w
for j in range(h):
off = base + j * stride_w * 3
fb[off:off + w * 3] = row
def decode_raw(c, fb, sw, x, y, w, h, bpp=4):
need = w * h * bpp
if need > 64 * 1048576:
from . import proto as _p
raise _p.RfbError("raw rect слишком большой")
data = c.read(need)
for j in range(h):
for i in range(w):
rgb = _px(data, (j * w + i) * bpp, bpp)
off = ((y + j) * sw + (x + i)) * 3
fb[off:off + 3] = bytes(rgb)
return [(x, y, w, h)]
def decode_copyrect(c, fb, sw, sh, x, y, w, h):
sx = c.u16()
sy = c.u16()
if max(sx, sy) > 8192:
from . import proto as _p
raise _p.RfbError("плохой copyrect src")
# Копируем построчно (перекрытие — через временный буфер строки).
for j in range(h):
if y + j >= sh or sy + j >= sh:
break
src = ((sy + j) * sw + sx) * 3
dst = ((y + j) * sw + x) * 3
row = bytes(fb[src:src + w * 3])
fb[dst:dst + w * 3] = row[:w * 3]
return [(x, y, w, h)]
def decode_rre(c, fb, sw, x, y, w, h, bpp=4):
n = c.u32()
if n > w * h:
from . import proto as _p
raise _p.RfbError("плохой rre count")
bg = _px(c.read(bpp), 0, bpp)
_blit(fb, sw, x, y, w, h, bg)
dirty = [(x, y, w, h)]
for _ in range(n):
rgb = _px(c.read(bpp), 0, bpp)
sx = c.u16()
sy = c.u16()
rw = c.u16()
rh = c.u16()
if sx + rw > w or sy + rh > h:
from . import proto as _p
raise _p.RfbError("плохой rre subrect")
_blit(fb, sw, x + sx, y + sy, rw, rh, rgb)
return dirty
def decode_hextile(c, fb, sw, x, y, w, h, bpp=4):
from . import proto as _p
bg = (0, 0, 0)
fg = (0, 0, 0)
for ty in range(0, h, 16):
th = min(16, h - ty)
for tx in range(0, w, 16):
tw = min(16, w - tx)
mask = c.u8()
if mask & 1: # Raw
data = c.read(tw * th * bpp)
for j in range(th):
for i in range(tw):
rgb = _px(data, (j * tw + i) * bpp, bpp)
off = ((y + ty + j) * sw + (x + tx + i)) * 3
fb[off:off + 3] = bytes(rgb)
continue
if mask & 2:
bg = _px(c.read(bpp), 0, bpp)
_blit(fb, sw, x + tx, y + ty, tw, th, bg)
if mask & 4:
fg = _px(c.read(bpp), 0, bpp)
if mask & 8:
n = c.u8()
if n > tw * th:
raise _p.RfbError("плохой hextile count")
for _ in range(n):
if mask & 16:
rgb = _px(c.read(bpp), 0, bpp)
else:
rgb = fg
sxy = c.u8()
wh = c.u8()
sx, sy = (sxy >> 4) & 15, sxy & 15
rw, rh = ((wh >> 4) & 15) + 1, (wh & 15) + 1
if sx + rw > tw or sy + rh > th:
raise _p.RfbError("плохой hextile subrect")
_blit(fb, sw, x + tx + sx, y + ty + sy, rw, rh, rgb)
return [(x, y, w, h)]
def _zrle_tile(data, pos, tw, th, bpp):
"""Один ZRLE-тайл (RFC 6143 §7.7.6) -> (RGB888 bytes, pos)."""
from . import proto as _p
if pos >= len(data):
raise _p.RfbError("обрезанный zrle")
mode = data[pos]
pos += 1
total = tw * th
if mode == 0: # raw
need = total * bpp
raw = data[pos:pos + need]
if len(raw) < need:
raise _p.RfbError("обрезанный zrle raw")
out = bytearray(total * 3)
for i in range(total):
r, g, b = _px(raw, i * bpp, bpp)
out[i * 3:i * 3 + 3] = bytes((r, g, b))
return bytes(out), pos + need
if mode == 1: # solid
if pos + bpp > len(data):
raise _p.RfbError("обрезанный zrle solid")
rgb = _px(data[pos:pos + bpp], 0, bpp)
return bytes(rgb) * total, pos + bpp
if 2 <= mode <= 16: # packed palette
pal_size = mode
pal = []
for _ in range(pal_size):
if pos + bpp > len(data):
raise _p.RfbError("обрезанный zrle pal")
pal.append(_px(data[pos:pos + bpp], 0, bpp))
pos += bpp
bpi = _bpp_bits(pal_size) # бит на индекс
out = bytearray()
i = 0
while i < total:
if pos >= len(data):
raise _p.RfbError("обрезанный zrle indices")
b = data[pos]
pos += 1
if b & 128: # RLE run: длина + индекс
run = (b & 127) + 1
if pos >= len(data):
raise _p.RfbError("обрезанный zrle run")
rgb = pal[data[pos] % pal_size]
pos += 1
out += bytes(rgb) * run
i += run
else: # packed indices в байте, от старшего бита
for k in range(8 // bpi):
if i >= total:
break
shift = 8 - bpi * (k + 1)
rgb = pal[((b >> shift) & (pal_size - 1)) % pal_size]
out += bytes(rgb)
i += 1
return bytes(out[:total * 3]), pos
if 128 <= mode <= 255: # plain RLE
out = bytearray()
while len(out) < total * 3:
if pos + bpp > len(data):
raise _p.RfbError("обрезанный zrle rle")
rgb = _px(data[pos:pos + bpp], 0, bpp)
pos += bpp
ln = mode & 127
while ln == 127:
if pos >= len(data):
raise _p.RfbError("обрезанный zrle runlen")
ln += data[pos]
pos += 1
out += bytes(rgb) * (ln + 1)
return bytes(out[:total * 3]), pos
raise _p.RfbError("неизвестный zrle mode: %d" % mode)
def _bpp_bits(pal_size):
if pal_size <= 2:
return 1
if pal_size <= 4:
return 2
return 4 # 5-16: по 4 бита (верхние биты pad)
class ZlibCtx:
"""Персистентный zlib decompressor (один на соединение)."""
def __init__(self):
self._d = zlib.decompressobj()
def feed(self, data):
try:
return self._d.decompress(data)
except zlib.error as e:
from . import proto as _p
raise _p.RfbError("zlib: %s" % str(e)[:100])
def decode_zrle(c, fb, sw, x, y, w, h, zctx, bpp=4):
from . import proto as _p
ln = c.u32()
if ln > 16 * 1048576:
raise _p.RfbError("zrle слишком большой: %d" % ln)
data = zctx.feed(c.read(ln))
pos = 0
for ty in range(0, h, 64):
for tx in range(0, w, 64):
tw = min(64, w - tx)
th = min(64, h - ty)
tile, pos = _zrle_tile(data, pos, tw, th, bpp)
for j in range(th):
off = ((y + ty + j) * sw + (x + tx)) * 3
fb[off:off + tw * 3] = tile[j * tw * 3:(j + 1) * tw * 3]
return [(x, y, w, h)]
def decode_rect(c, fb, sw, sh, x, y, w, h, enc, zctx=None, bpp=4):
"""Диспетчер. Вернуть dirty-rects."""
from . import proto as _p
if x + w > sw or y + h > sh:
raise _p.RfbError("rect вне экрана")
if enc == _p.ENC_RAW:
return decode_raw(c, fb, sw, x, y, w, h, bpp)
if enc == _p.ENC_COPYRECT:
return decode_copyrect(c, fb, sw, sh, x, y, w, h)
if enc == _p.ENC_RRE:
return decode_rre(c, fb, sw, x, y, w, h, bpp)
if enc == _p.ENC_HEXTILE:
return decode_hextile(c, fb, sw, x, y, w, h, bpp)
if enc == _p.ENC_ZRLE:
if zctx is None:
zctx = ZlibCtx()
return decode_zrle(c, fb, sw, x, y, w, h, zctx, bpp)
if enc in (_p.ENC_CURSOR, _p.ENC_DESKTOPSIZE, _p.ENC_LASTRECT,
_p.ENC_EXT_DESKTOPSIZE):
# Pseudo: данные съесть (cursor) или их нет (размер меняет вызывающий).
if enc == _p.ENC_CURSOR:
c.read(w * h * bpp + ((w + 7) // 8) * h)
return []
raise _p.RfbError("неподдерживаемая кодировка: %d" % enc)
rfb/frame.py 3.5 КБ
"""remotedesk.rfb.frame — серверный framebuffer + PNG-патчи для canvas.
fb — bytearray RGB888 (width*height*3). dirty-накопление: WS-цикл
забирает take_dirty() и шлёт каждый rect PNG (zlib+struct, без pillow).
"""
import struct
import zlib
class Frame:
def __init__(self, width, height):
if not (1 <= width <= 8192 and 1 <= height <= 8192):
raise ValueError("плохой размер кадра")
self.width = width
self.height = height
self.fb = bytearray(width * height * 3)
self._dirty = []
def apply(self, rects):
for r in rects or []:
self._dirty.append(tuple(r))
def take_dirty(self, merge=True):
"""Забрать dirty-rects (с merge пересекающихся в bounding box).
Умный merge: сливаем в bbox только если он плотный (площадь bbox
<= 2.5x суммы площадей) — иначе один bbox тянет кодирование
и трафик за unchanged-область (типично: курсор + часы в трее
сливались во весь экран). Разрозненные rects шлём по отдельности
(мелкие PNG кодируются <1мс), кап 16 штук — хвост сливаем в bbox.
"""
rects = self._dirty
self._dirty = []
if not merge or len(rects) < 2:
return [tuple(r) for r in rects]
if len(rects) > 16:
# Шторм rects — один bbox (кап числа PNG/кадр).
return _merge_all(rects)
xs = [r[0] for r in rects]
ys = [r[1] for r in rects]
xe = [r[0] + r[2] for r in rects]
ye = [r[1] + r[3] for r in rects]
x0, y0 = min(xs), min(ys)
bw, bh = max(xe) - x0, max(ye) - y0
area = sum(r[2] * r[3] for r in rects)
if area > 0 and bw * bh <= int(area * 2.5):
return [(x0, y0, bw, bh)]
return [tuple(r) for r in rects]
def crop_png(self, x, y, w, h, level=1):
"""Rect -> PNG bytes (color-type 2, без pillow).
level=1: fullscreen 28мс -> 13мс (+9% размера); мелкие rects
разница нулевая. Десктопные пиксели жмутся и на 1.
"""
x = max(0, min(x, self.width - 1))
y = max(0, min(y, self.height - 1))
w = max(1, min(w, self.width - x))
h = max(1, min(h, self.height - y))
raw = bytearray()
for j in range(h):
raw.append(0) # filter None
off = ((y + j) * self.width + x) * 3
raw += self.fb[off:off + w * 3]
return _encode_png(w, h, bytes(raw), level)
def _merge_all(rects):
"""Все rects в один bbox."""
xs = [r[0] for r in rects]
ys = [r[1] for r in rects]
xe = [r[0] + r[2] for r in rects]
ye = [r[1] + r[3] for r in rects]
x0, y0 = min(xs), min(ys)
return [(x0, y0, max(xe) - x0, max(ye) - y0)]
def _chunk(typ, data):
out = struct.pack(">L", len(data)) + typ + data
out += struct.pack(">L", zlib.crc32(typ + data) & 0xFFFFFFFF)
return out
def _encode_png(w, h, raw_rgb_scanlines, level=1):
ihdr = struct.pack(">LLBBBBB", w, h, 8, 2, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr)
+ _chunk(b"IDAT", zlib.compress(raw_rgb_scanlines, level))
+ _chunk(b"IEND", b""))
rfb/proto.py 8.8 КБ
"""remotedesk.rfb.proto — RFB 3.8 handshake + auth (RFC 6143 §7.1-7.3).
Security: None/VNC-Auth (DES); Plain/TLS запрещены.
Пиксели: запрос 32-bit LE, декодер отдаёт RGB888.
"""
import logging
import socket
import struct
logger = logging.getLogger(__name__)
ENC_RAW = 0
ENC_COPYRECT = 1
ENC_RRE = 2
ENC_HEXTILE = 5
ENC_TRLE = 15
ENC_ZRLE = 16
ENC_CURSOR = -239
ENC_DESKTOPSIZE = -223
ENC_TIGHT = 7
ENC_TIGHT_PNG = -260 # TightPNG (tightvnc extension; только если pillow)
ENC_LASTRECT = -224
ENC_EXT_DESKTOPSIZE = -308
#: SetEncodings без LastRect (маркер 0xFFFF без запроса ломает парсинг).
PREFERRED_ENCODINGS = (ENC_ZRLE, ENC_HEXTILE, ENC_RRE, ENC_COPYRECT,
ENC_CURSOR, ENC_DESKTOPSIZE)
MAX_NAME_LEN = 256
MAX_DIM = 8192
MAX_RECTS = 512
class RfbError(ValueError):
pass
class Conn:
"""Сокет + буфер чтения с bounds-check (защита парсеров)."""
def __init__(self, sock, timeout=10):
self.sock = sock
self.sock.settimeout(timeout)
self.buf = bytearray()
def _fill(self, n):
while len(self.buf) < n:
try:
chunk = self.sock.recv(max(4096, n - len(self.buf)))
except socket.timeout:
raise RfbError("таймаут чтения RFB")
except OSError as e:
raise RfbError("обрыв RFB: %s" % str(e)[:100])
if not chunk:
raise RfbError("сервер закрыл соединение")
self.buf += chunk
if len(self.buf) > 64 * 1048576:
raise RfbError("переполнение буфера RFB")
def read(self, n):
if n < 0 or n > 16 * 1048576:
raise RfbError("плохая длина чтения: %d" % n)
self._fill(n)
out = bytes(self.buf[:n])
del self.buf[:n]
return out
def u8(self):
return self.read(1)[0]
def u16(self):
return struct.unpack(">H", self.read(2))[0]
def u32(self):
return struct.unpack(">L", self.read(4))[0]
def s32(self):
return struct.unpack(">l", self.read(4))[0]
def write(self, data):
try:
self.sock.sendall(data)
except OSError as e:
raise RfbError("запись RFB: %s" % str(e)[:100])
def close(self):
try:
self.sock.close()
except Exception: # noqa: BLE001
pass
def _vnc_desseed(password):
"""Ключ DES из пароля: truncate/pad до 8 + bitswap каждого байта."""
pw = (password or "").encode("latin-1", errors="ignore")[:8]
pw = pw + b"\x00" * (8 - len(pw))
out = bytearray()
for b in pw:
v = 0
for i in range(8):
if b & (1 << i):
v |= 1 << (7 - i)
out.append(v)
return bytes(out)
def vnc_auth_response(password, challenge):
"""DES-шифр challenge (16 байт) ключом из пароля. Нужен pycryptodome."""
if len(challenge) != 16:
raise RfbError("плохой challenge VNC-auth")
try:
from Crypto.Cipher import DES as _DES
except ImportError:
raise RfbError("нет pycryptodome (нужен для VNC-auth)")
key = _vnc_desseed(password or "")
out = b""
for off in (0, 8):
out += _DES.new(key, _DES.MODE_ECB).encrypt(challenge[off:off + 8])
return out
def handshake(host, port, password="", timeout=10, shared=True):
"""Полный handshake. Вернуть (Conn, {width, height, name})."""
try:
sock = socket.create_connection((host, int(port or 5900)),
timeout=timeout)
except Exception as e: # noqa: BLE001
raise RfbError("vnc %s: %s" % (host, str(e)[:150]))
c = Conn(sock, timeout)
# 1. ProtocolVersion.
srv = c.read(12)
if not srv.startswith(b"RFB "):
c.close()
raise RfbError("не RFB: %r" % srv[:12])
c.write(b"RFB 003.008\n")
# 2. Security.
ntypes = c.u8()
if ntypes == 0:
reason = _read_reason(c)
c.close()
raise RfbError("сервер отказал: %s" % reason)
if ntypes > 16:
c.close()
raise RfbError("слишком много security-типов: %d" % ntypes)
types = [c.u8() for _ in range(ntypes)]
if 2 in types and (password or ""):
chosen = 2
elif 1 in types:
chosen = 1
elif 2 in types:
c.close()
raise RfbError("сервер требует пароль (VNC-auth), пароль не задан")
else:
c.close()
raise RfbError("нет общего security-типа: %s (нужны None/VNC-auth)"
% types)
c.write(bytes([chosen]))
if chosen == 2:
challenge = c.read(16)
c.write(vnc_auth_response(password, challenge))
# 3. SecurityResult.
status = c.u32()
if status != 0:
reason = _read_reason(c)
c.close()
raise RfbError("аутентификация не удалась: %s" % reason)
# 4. ClientInit + ServerInit.
c.write(bytes([1 if shared else 0]))
width = c.u16()
height = c.u16()
pixfmt = c.read(16)
name_len = c.u32()
if name_len > MAX_NAME_LEN:
c.close()
raise RfbError("слишком длинное имя десктопа: %d" % name_len)
name = c.read(name_len).decode("utf-8", errors="replace")
if not (1 <= width <= MAX_DIM and 1 <= height <= MAX_DIM):
c.close()
raise RfbError("плохой размер: %dx%d" % (width, height))
bpp = pixfmt[0]
if bpp not in (8, 16, 32):
logger.warning("remotedesk vnc: серверный bpp=%s, просим 32", bpp)
# Просим свой формат: 32-bit true-color LE.
set_pixel_format(c)
set_encodings(c, list(PREFERRED_ENCODINGS))
return c, {"width": width, "height": height, "name": name}
def _read_reason(c):
try:
ln = c.u32()
if ln > 1024:
return "?"
return c.read(ln).decode("utf-8", errors="replace")
except RfbError:
return "?"
def set_pixel_format(c):
"""32-bit true-color LE (R8G8B8, little-endian)."""
c.write(b"\x00\x00\x00\x00" + struct.pack(
">BBBBHHHBBB3x", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0))
def set_encodings(c, encs):
c.write(struct.pack(">BBH", 2, 0, len(encs)))
for e in encs:
c.write(struct.pack(">l", int(e)))
def request_update(c, x, y, w, h, incremental=True):
w = max(1, min(w, MAX_DIM))
h = max(1, min(h, MAX_DIM))
c.write(struct.pack(">BBHHHH", 3, 1 if incremental else 0,
x, y, w, h))
def send_pointer(c, x, y, buttons=0):
c.write(struct.pack(">BBHH", 5, buttons & 0xFF, x & 0xFFFF,
y & 0xFFFF))
def send_key(c, keysym, down=True):
c.write(struct.pack(">BBH L", 4, 1 if down else 0, 0,
keysym & 0xFFFFFFFF))
def send_cut_text(c, text):
raw = (text or "").encode("utf-8", errors="ignore")[:65536]
c.write(struct.pack(">BxxxL", 6, len(raw)) + raw)
def read_message(c):
"""Читать server message -> (type, payload).
FramebufferUpdate возвращает только число rects (rects — по одному).
"""
t = c.u8()
if t == 0: # FramebufferUpdate
c.read(1) # padding
n = c.u16()
if n == 0xFFFF:
# Маркер LastRect без запроса: съесть, rects нет.
x = c.u16()
y = c.u16()
w = c.u16()
h = c.u16()
enc = c.s32()
if enc != ENC_LASTRECT:
raise RfbError("плохой lastrect-маркер: %d" % enc)
return 0, {"n_rects": 0}
if n > MAX_RECTS:
raise RfbError("слишком много rects: %d" % n)
return 0, {"n_rects": n}
if t == 1: # SetColorMapEntries (игнор — мы true-color)
c.read(1)
first = c.u16()
n = c.u16()
c.read(n * 6)
return 1, {"first": first, "n": n}
if t == 2: # Bell
return 2, {}
if t == 3: # ServerCutText
c.read(3)
ln = c.u32()
if ln > 1048576:
raise RfbError("cut-text слишком длинный")
return 3, {"text": c.read(ln).decode("latin-1", errors="replace")}
raise RfbError("неизвестный server message: %d" % t)
def read_rect_header(c):
"""Один заголовок rect (x, y, w, h, enc) — читать строго перед данными."""
x = c.u16()
y = c.u16()
w = c.u16()
h = c.u16()
enc = c.s32()
if w > MAX_DIM or h > MAX_DIM:
raise RfbError("плохой rect %dx%d" % (w, h))
if w * h > MAX_DIM * MAX_DIM:
raise RfbError("rect слишком большой")
return x, y, w, h, enc
session.py 3.0 КБ
"""remotedesk.session — реестр живых SSH-сессий (память процесса).
Ключ: "web:<uid>:<conn_id>". Лимиты: MAX_SESSIONS_PER_USER на
пользователя, SESSION_TTL_MIN простоя (janitor закрывает).
Секреты здесь не хранятся — только живые client/channel.
"""
import logging
import threading
import time
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_sessions = {} # key -> {"client", "channel", "uid", "conn_id", "at", "cols", "rows"}
def _cfg(ctx):
try:
cfg = ctx.module_config("remotedesk") if hasattr(ctx, "module_config") else {}
except Exception: # noqa: BLE001
cfg = {}
try:
ttl = max(1, min(480, int((cfg.get("SESSION_TTL_MIN") or "30") or 30)))
except (TypeError, ValueError):
ttl = 30
try:
per_user = max(1, min(16, int((cfg.get("MAX_SESSIONS_PER_USER") or "2") or 2)))
except (TypeError, ValueError):
per_user = 2
return ttl * 60, per_user
def _janitor(ctx):
ttl, _ = _cfg(ctx)
now = time.time()
dead = []
with _lock:
for key, rec in _sessions.items():
if now - rec.get("at", now) > ttl:
dead.append(key)
for key in dead:
rec = _sessions.pop(key, None)
if rec:
_close_rec(rec)
def _close_rec(rec):
for obj in (rec.get("channel"), rec.get("client")):
try:
if obj is not None:
obj.close()
except Exception: # noqa: BLE001
pass
def open_session(ctx, uid, conn_id, client, channel, cols=80, rows=24):
"""Зарегистрировать сессию. Вернуть (key, err)."""
_, per_user = _cfg(ctx)
_janitor(ctx)
key = "web:%s:%s" % (uid, conn_id)
with _lock:
n = sum(1 for r in _sessions.values() if r.get("uid") == uid)
if n >= per_user and key not in _sessions:
return "", "лимит сессий (%d) — закройте лишние" % per_user
old = _sessions.pop(key, None)
if old:
_close_rec(old)
_sessions[key] = {"client": client, "channel": channel,
"uid": uid, "conn_id": conn_id,
"at": time.time(), "cols": cols, "rows": rows}
return key, ""
def touch(key):
with _lock:
rec = _sessions.get(key)
if rec:
rec["at"] = time.time()
return rec
def get(key):
with _lock:
return _sessions.get(key)
def close_session(key):
with _lock:
rec = _sessions.pop(key, None)
if rec:
_close_rec(rec)
def close_user(uid):
with _lock:
keys = [k for k, r in _sessions.items() if r.get("uid") == uid]
for k in keys:
rec = _sessions.pop(k, None)
if rec:
_close_rec(rec)
def stats():
with _lock:
return {"sessions": len(_sessions),
"users": len({r.get("uid") for r in _sessions.values()})}
ssh_client.py 25.3 КБ
"""remotedesk.ssh_client — SSH/SFTP поверх paramiko.
Аутентификация: пароль либо приватный ключ (PEM/OpenSSH из креда).
Host-key: TOFU — при первом коннекте серверный ключ возвращается
как fingerprint (ошибка need_pin), фронт показывает «доверять?»,
повторный коннект идёт с params.host_key_pin. Строгий пин тоже
хранится в params (не секрет — открытый ключ сервера).
SFTP идёт тем же транспортом (один TCP-коннект на сессию).
"""
import base64
import io
import logging
import time
logger = logging.getLogger(__name__)
def fingerprint(key):
"""Отпечаток ключа сервера: 'sha256:BASE64...' (честный SHA256)."""
import hashlib as _h
try:
digest = _h.sha256(key.asbytes()).digest()
return "sha256:" + base64.b64encode(digest).decode().rstrip("=")
except Exception: # noqa: BLE001
return "?"
def fingerprint_legacy_md5(key):
"""Старый формат пина (paramiko get_fingerprint = MD5).
Сохранённые до фикса пины — MD5 под меткой sha256:. Принимаем оба,
новый сохраняется честным (см. ui_ws pin-save).
"""
try:
digest = key.get_fingerprint()
return "sha256:" + base64.b64encode(digest).decode().rstrip("=")
except Exception: # noqa: BLE001
return "?"
class NeedPin(Exception):
"""Серверный host-key неизвестен — нужен TOFU-пин (fingerprint в args)."""
def __init__(self, fp, key_b64=""):
super().__init__("нужен host-key пин: %s" % fp)
self.fp = fp
self.key_b64 = key_b64
def _paramiko():
try:
import paramiko as _p
except ImportError:
raise ValueError("нет пакета paramiko "
"(requirements.txt модуля remotedesk)")
return _p
def _server_key_b64(key):
try:
return base64.b64encode(key.asbytes()).decode()
except Exception: # noqa: BLE001
return ""
def connect(host, port=22, username="", secret="", params=None,
timeout=10):
"""Подключиться по SSH. Вернуть paramiko.SSHClient.
params: {key_passphrase?, host_key_pin? (fingerprint),
connect_via? (не поддерживается в v1 — явная ошибка)}.
secret: пароль; если начинается с '-----BEGIN' — приватный ключ
(passphrase — из params.key_passphrase).
"""
pm = _paramiko()
params = params or {}
if (params.get("connect_via") or "").strip():
raise ValueError("jump-host (connect_via) не поддерживается")
client = pm.SSHClient()
sock_timeout = max(2, min(60, int(timeout or 10)))
pin = ((params.get("host_key_pin") or "").strip())
class _PinPolicy(pm.MissingHostKeyPolicy):
"""TOFU-сверка в ЕДИНСТВЕННОМ хендшейке (без пре-флайта).
До фикса было два коннекта: пре-флайт Transport проверял пин,
затем client.connect() шёл с AutoAddPolicy (любой ключ) — окно
для MITM-подмены между хендшейками. Теперь один коннект, ключ
сверяется здесь же; несовпадение/отсутствие пина — исключение.
"""
def missing_host_key(self, _client, _hostname, key):
if pin and (fingerprint(key) == pin
or fingerprint_legacy_md5(key) == pin):
return
if pin:
raise ValueError(
"host-key НЕ совпал с пином (сервер дал %s)" % (
fingerprint(key)))
raise NeedPin(fingerprint(key), _server_key_b64(key))
client.set_missing_host_key_policy(_PinPolicy())
pkey = None
password = None
text = (secret or "")
if text.strip().startswith("-----BEGIN"):
bio = io.StringIO(text)
pp = params.get("key_passphrase") or None
# getattr-фильтр: DSSKey выпилен из paramiko>=3 (AttributeError
# ломал ЛЮБОЙ логин по ключу до try).
key_classes = [getattr(pm, n, None) for n in
("RSAKey", "Ed25519Key", "ECDSAKey", "DSSKey")]
for cls in [c for c in key_classes if c is not None]:
try:
bio.seek(0)
pkey = cls.from_private_key(bio, password=pp)
break
except Exception: # noqa: BLE001
pkey = None
if pkey is None:
raise ValueError("приватный ключ не разобран "
"(нужна key_passphrase?)")
elif text:
password = text
try:
client.connect(host, port=int(port or 22),
username=username or None,
password=password, pkey=pkey,
timeout=sock_timeout,
banner_timeout=sock_timeout,
auth_timeout=sock_timeout,
allow_agent=False, look_for_keys=False)
except (NeedPin, ValueError):
# TOFU/пин из policy — наружу как есть (не заворачивать).
try:
client.close()
except Exception: # noqa: BLE001
pass
raise
except Exception as e: # noqa: BLE001
try:
client.close()
except Exception: # noqa: BLE001
pass
raise ValueError("ssh %s: %s" % (host, str(e)[:200]))
return client
def open_shell(client, cols=80, rows=24):
"""Интерактивный канал (invoke_shell). Вернуть Channel."""
try:
tr = client.get_transport() if client is not None else None
except Exception: # noqa: BLE001
tr = None
if tr is None or not tr.is_active():
raise ValueError("shell: нет активного транспорта")
try:
ch = tr.open_session()
ch.get_pty("xterm-256color", cols, rows)
ch.invoke_shell()
except Exception as e: # noqa: BLE001
raise ValueError("shell: %s" % str(e)[:200])
return ch
# --- SFTP поверх того же транспорта ---
_SFTP_ATTRS = ("filename", "st_size", "st_mtime", "st_mode")
def _safe_remote(path):
"""Проверка remote-пути: без NUL/CR/LF и ..-сегментов."""
p = (path or "").strip()
if not p or "\x00" in p or "\r" in p or "\n" in p:
return ""
segs = p.replace("\\", "/").split("/")
if any(s == ".." for s in segs):
return ""
return p
def sftp_list(client, path, limit=500):
"""Содержимое каталога [{name, dir, size, mtime}]."""
import stat as _stat
pm = _paramiko()
remote = _safe_remote(path) or "."
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
entries = sftp.listdir_attr(remote)
except OSError as e:
import errno as _er
if e.errno == _er.ENOENT:
raise ValueError("не каталог: %s" % path)
raise ValueError("sftp list %s: %s" % (remote, str(e)[:160]))
except Exception as e: # noqa: BLE001
raise ValueError("sftp list: %s" % str(e)[:200])
out = []
for a in entries[:max(1, min(limit or 500, 2000))]:
try:
isdir = _stat.S_ISDIR(a.st_mode or 0)
except Exception: # noqa: BLE001
isdir = False
out.append({
"name": a.filename,
"dir": bool(isdir),
"size": a.st_size or 0,
"mtime": time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(a.st_mtime or 0)),
})
out.sort(key=lambda e: (not e["dir"], e["name"].lower()))
return {"path": remote, "entries": out}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_read(client, path, max_bytes=1048576):
"""Прочитать удалённый файл (текст utf-8 или base64 для бинаря)."""
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
st = sftp.stat(remote)
except OSError as e:
import errno as _er2
if e.errno == _er2.ENOENT:
raise ValueError("не файл: %s" % path)
raise ValueError("sftp stat %s: %s" % (remote,
str(e)[:160]))
except Exception as e: # noqa: BLE001
raise ValueError("sftp stat: %s" % str(e)[:200])
import stat as _stat
if _stat.S_ISDIR(st.st_mode or 0):
raise ValueError("это каталог: %s" % path)
if (st.st_size or 0) > max_bytes:
raise ValueError("файл больше %d Б" % max_bytes)
try:
with sftp.open(remote, "rb") as f:
data = f.read(max_bytes + 1)
except Exception as e: # noqa: BLE001
raise ValueError("sftp read: %s" % str(e)[:200])
if len(data) > max_bytes:
raise ValueError("файл больше %d Б" % max_bytes)
try:
return {"path": remote, "text": data.decode("utf-8"),
"binary": False}
except UnicodeDecodeError:
return {"path": remote,
"text": base64.b64encode(data).decode(),
"binary": True}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_write(client, path, data, max_bytes=1048576):
"""Записать удалённый файл (text str или bytes).
Сначала атомарно (tmp+rename), при отказе rename — напрямую в цель:
rename требует прав на КАТАЛОГ, а сам файл править можно
(типично: свой файл в чужом каталоге — open проходит, rename
отвечает голым "Failure"). Прямая запись тогда спасает.
tmp с PID+рандомом: фиксированное имя коллизировало при
конкурентных записях (один затирал tmp другого).
"""
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
raw = data.encode("utf-8") if isinstance(data, str) else (data or b"")
if len(raw) > max_bytes:
raise ValueError("данные больше %d Б" % max_bytes)
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
import os as _os
import random as _rnd
tmp = "%s.tmp-botmod-%d-%d" % (remote, _os.getpid(),
_rnd.randrange(1 << 30))
try:
with sftp.open(tmp, "wb") as f:
f.write(raw)
except Exception as e: # noqa: BLE001
raise ValueError("sftp write tmp %s: %s" % (tmp, str(e)[:160]))
try:
sftp.rename(tmp, remote)
except Exception as e: # noqa: BLE001
rename_err = str(e)[:120]
try:
sftp.remove(tmp)
except Exception: # noqa: BLE001
pass
try:
with sftp.open(remote, "wb") as f:
f.write(raw)
except Exception as e2: # noqa: BLE001
raise ValueError(
"sftp write %s: %s (rename: %s)" % (
remote, str(e2)[:120], rename_err))
return {"ok": True, "path": remote, "size": len(raw)}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_mkdir(client, path):
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
sftp.mkdir(remote)
except Exception as e: # noqa: BLE001
raise ValueError("sftp mkdir: %s" % str(e)[:200])
return {"ok": True, "path": remote}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_delete(client, path):
"""Удалить файл (каталоги — только пустые через rmdir)."""
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
sftp.remove(remote)
return {"ok": True, "path": remote}
except Exception as e_rm: # noqa: BLE001
rm_err = str(e_rm)[:160]
try:
sftp.rmdir(remote)
return {"ok": True, "path": remote}
except Exception as e: # noqa: BLE001
raise ValueError("sftp delete %s: %s (remove: %s)" % (
remote, str(e)[:120], rm_err))
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_rename(client, src, dst):
"""Переименовать/переместить удалённый файл или каталог."""
old = _safe_remote(src)
new = _safe_remote(dst)
if not old:
raise ValueError("плохой путь: %s" % src)
if not new:
raise ValueError("плохой путь: %s" % dst)
if old == new:
raise ValueError("имена совпадают")
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
sftp.rename(old, new)
except Exception as e: # noqa: BLE001
raise ValueError("sftp rename: %s" % str(e)[:200])
return {"ok": True, "path": new}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_chmod(client, path, mode):
"""Сменить права (octal str '755' или int 0o755/755).
int трактуем как уже-octal (0o755), строку — как octal-строку.
Кап 0o777: setuid/setgid/sticky из веба не даём (только root
руками через терминал).
"""
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
try:
if isinstance(mode, int) and not isinstance(mode, bool):
m = mode
else:
m = int(str(mode).strip(), 8)
except (TypeError, ValueError):
raise ValueError("плохие права: %r" % (mode,))
if not 0 <= m <= 0o777:
raise ValueError("права вне 0..777: %r" % (mode,))
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
sftp.chmod(remote, m)
except Exception as e: # noqa: BLE001
raise ValueError("sftp chmod %s: %s" % (remote,
str(e)[:160]))
return {"ok": True, "path": remote, "mode": oct(m)}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_chown(client, path, uid=None, gid=None):
"""Сменить владельца/группу (ЧИСЛА; None — не менять).
Имена НЕ резолвим: локальные pwd/grp стенда ≠ NSS удалённого хоста
(молча не тому uid). Для имён — числовые uid/gid.
"""
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
if uid is None and gid is None:
raise ValueError("нечего менять: задайте uid и/или gid")
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
uid = -1 if uid is None else int(str(uid).strip())
gid = -1 if gid is None else int(str(gid).strip())
except (TypeError, ValueError):
raise ValueError("uid/gid — только числа: %r/%r" % (uid,
gid))
if uid == -1 and gid == -1:
raise ValueError("uid/gid -1 запрещены")
try:
sftp.chown(remote, uid, gid)
except Exception as e: # noqa: BLE001
raise ValueError("sftp chown %s: %s" % (remote,
str(e)[:160]))
return {"ok": True, "path": remote, "uid": uid, "gid": gid}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_walk(client, sftp, remote, depth=0, max_depth=32,
limit=10000, _count=None):
"""Рекурсивный обход каталога: [(path, isdir, size)].
Лимит проверяется ПО ХОДУ (ранний выход): обойти всё дерево, а
потом сказать «много» — DoS по сети/времени/памяти. Симлинки на
каталоги не спускаемся (lstat-семантика listdir_attr: S_ISLNK —
как файл, иначе уход за дерево/циклы).
"""
import stat as _stat
if _count is None:
_count = [0]
if depth > max_depth:
raise ValueError("слишком глубокая вложенность: %s" % remote)
try:
entries = sftp.listdir_attr(remote)
except Exception as e: # noqa: BLE001
raise ValueError("sftp list %s: %s" % (remote, str(e)[:160]))
out = []
for a in entries:
_count[0] += 1
if _count[0] > limit:
raise ValueError("слишком много записей (>%d)" % limit)
p = remote.rstrip("/") + "/" + a.filename
try:
mode = a.st_mode or 0
isdir = _stat.S_ISDIR(mode) and not _stat.S_ISLNK(mode)
except Exception: # noqa: BLE001
isdir = False
out.append((p, isdir, a.st_size or 0))
if isdir:
out.extend(sftp_walk(client, sftp, p, depth + 1, max_depth,
limit, _count))
return out
def sftp_rmtree(client, path, max_entries=10000):
"""Рекурсивно удалить дерево. Лимит записей против rm -rf /."""
remote = _safe_remote(path)
if not remote or remote in ("/", ".", "~"):
raise ValueError("отказ: %s" % path)
# Deny-list системных префиксов: одна операция из веба не должна
# сносить систему/дом юзера целиком.
import posixpath as _pp
norm = _pp.normpath("/" + remote.lstrip("/"))
for deny in ("/etc", "/root", "/var", "/usr", "/bin", "/sbin",
"/boot", "/proc", "/sys", "/dev"):
if norm == deny or norm.startswith(deny + "/"):
raise ValueError("отказ (системный путь): %s" % path)
if norm in ("/home",) or norm.count("/") == 2 and norm.startswith(
"/home/"):
raise ValueError("отказ (дом целиком): %s" % path)
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
st = sftp.stat(remote)
except OSError as e:
import errno as _er3
if e.errno == _er3.ENOENT:
raise ValueError("нет пути: %s" % path)
raise ValueError("sftp stat %s: %s" % (remote,
str(e)[:160]))
except Exception as e: # noqa: BLE001
raise ValueError("sftp stat: %s" % str(e)[:200])
import stat as _stat
if not _stat.S_ISDIR(st.st_mode or 0):
try:
sftp.remove(remote)
except Exception as e: # noqa: BLE001
raise ValueError("sftp delete %s: %s" % (
remote, str(e)[:160]))
return {"ok": True, "path": remote, "removed": 1}
# Лимит внутри walk (ранний выход, не постфактум).
entries = sftp_walk(client, sftp, remote, limit=max_entries)
# Сначала файлы, потом каталоги снизу вверх.
for p, isdir, _sz in entries:
if not isdir:
try:
sftp.remove(p)
except Exception as e: # noqa: BLE001
raise ValueError("sftp delete %s: %s" % (
p, str(e)[:160]))
for p, isdir, _sz in sorted(
(e for e in entries if e[1]), reverse=True):
try:
sftp.rmdir(p)
except Exception as e: # noqa: BLE001
raise ValueError("sftp rmdir %s: %s" % (p, str(e)[:160]))
try:
sftp.rmdir(remote)
except Exception as e: # noqa: BLE001
raise ValueError("sftp rmdir %s: %s" % (remote,
str(e)[:160]))
return {"ok": True, "path": remote,
"removed": len(entries) + 1}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
def sftp_zip_tree(client, path, max_bytes=1048576, max_entries=2000):
"""Скачать дерево ZIP-байтами (каталог -> zip, файл -> как есть)."""
import io as _io
import stat as _stat
import zipfile as _zip
remote = _safe_remote(path)
if not remote:
raise ValueError("плохой путь: %s" % path)
try:
sftp = client.open_sftp()
except Exception as e: # noqa: BLE001
raise ValueError("sftp: %s" % str(e)[:200])
try:
try:
st = sftp.stat(remote)
except OSError as e:
import errno as _er4
if e.errno == _er4.ENOENT:
raise ValueError("нет пути: %s" % path)
raise ValueError("sftp stat %s: %s" % (remote,
str(e)[:160]))
except Exception as e: # noqa: BLE001
raise ValueError("sftp stat: %s" % str(e)[:200])
if not _stat.S_ISDIR(st.st_mode or 0):
with sftp.open(remote, "rb") as f:
data = f.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError("файл больше %d Б" % max_bytes)
return {"path": remote, "data": data, "zipped": False}
# Лимит внутри walk (ранний выход, не постфактум).
entries = sftp_walk(client, sftp, remote, limit=max_entries)
buf = _io.BytesIO()
total = 0
base = remote.rstrip("/") + "/"
with _zip.ZipFile(buf, "w", _zip.ZIP_DEFLATED) as zf:
for p, isdir, sz in entries:
if isdir:
continue
if total + sz > max_bytes:
raise ValueError(
"дерево больше %d Б (обрезано на %s)" % (
max_bytes, p))
try:
with sftp.open(p, "rb") as f:
data = f.read(max_bytes - total + 1)
except Exception as e: # noqa: BLE001
raise ValueError("sftp read %s: %s" % (
p, str(e)[:160]))
total += len(data)
if total > max_bytes:
raise ValueError(
"дерево больше %d Б (обрезано на %s)" % (
max_bytes, p))
# ZipSlip-guard: злонамеренный сервер может отдать .. или
# абсолютные имена — такие записи пропускаем.
name = p[len(base):] if p.startswith(base) else p
import posixpath as _pp2
name = _pp2.normpath(name)
if (not name or name.startswith("..")
or name.startswith("/") or "/../" in name):
continue
zf.writestr(name, data)
return {"path": remote + ".zip", "data": buf.getvalue(),
"zipped": True}
finally:
try:
sftp.close()
except Exception: # noqa: BLE001
pass
store.py 6.3 КБ
"""remotedesk.store — единственное место IO connections.json.
Секретов здесь нет и быть не должно: файл хранит только ссылки
Секретов здесь нет и быть не должно: только ссылки cred_ref/auth_ref.
"""
import json
import os
import re
import time
PROTOCOLS = ("ssh", "vnc", "rdp")
DEFAULT_PORTS = {"ssh": 22, "vnc": 5900, "rdp": 3389}
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _write_json(path, data):
tmp = path + ".tmp"
d = os.path.dirname(os.path.abspath(path))
os.makedirs(d, exist_ok=True)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=1, ensure_ascii=False)
os.replace(tmp, path)
class Store:
def __init__(self, path):
self.path = path
def load(self):
data = _read_json(self.path)
conns = data.get("connections")
return conns if isinstance(conns, dict) else {}
def save(self, conns):
_write_json(self.path, {"version": 1, "connections": conns or {}})
def public(self, rec):
"""Публичная проекция записи (без секретов — их тут и нет)."""
params = rec.get("params")
return {
"id": rec.get("id", ""),
"name": rec.get("name", ""),
"protocol": rec.get("protocol", ""),
"group": rec.get("group", ""),
"host": rec.get("host", ""),
"port": rec.get("port", 0),
"params": dict(params) if isinstance(params, dict) else {},
"auth_ref": rec.get("auth_ref", ""),
"cred_ref": rec.get("cred_ref", ""),
"owners": list(rec.get("owners") or []),
"updated_at": rec.get("updated_at", ""),
}
def list(self):
return [self.public(v) for _, v in sorted(self.load().items())]
def validate(self, fields, partial=False):
"""Проверить поля. Вернуть (norm, err)."""
if not isinstance(fields, dict):
return None, "нужен объект"
norm = {}
if not partial or "name" in fields:
name = str(fields.get("name") or "").strip()
if not name or len(name) > 128:
return None, "нужно name (1-128 символов)"
norm["name"] = name
if not partial or "protocol" in fields:
proto = str(fields.get("protocol") or "").strip().lower()
if proto not in PROTOCOLS:
return None, "protocol: ssh|vnc|rdp"
norm["protocol"] = proto
if not partial or "host" in fields:
host = str(fields.get("host") or "").strip()
if not host or len(host) > 253 or any(
c in host for c in " \t\r\n"):
return None, "нужен host"
norm["host"] = host
if "port" in fields and fields.get("port") not in (None, ""):
try:
port = int(fields.get("port"))
except (TypeError, ValueError):
return None, "port: 1-65535"
if not 1 <= port <= 65535:
return None, "port: 1-65535"
norm["port"] = port
if "group" in fields:
group = str(fields.get("group") or "").strip()
if len(group) > 64:
return None, "group: до 64 символов"
norm["group"] = group
if "params" in fields:
params = fields.get("params")
if params is None:
norm["params"] = {}
elif isinstance(params, dict):
norm["params"] = {str(k): v for k, v in params.items()}
else:
return None, "params: объект"
if "auth_ref" in fields:
norm["auth_ref"] = str(fields.get("auth_ref") or "").strip()
if "cred_ref" in fields:
norm["cred_ref"] = str(fields.get("cred_ref") or "").strip()
if "owners" in fields:
owners = fields.get("owners")
if owners is None:
norm["owners"] = []
elif isinstance(owners, list) and all(
isinstance(x, str) for x in owners):
norm["owners"] = owners[:64]
else:
return None, "owners: список строк"
return norm, ""
def add(self, fields, actor=""):
norm, err = self.validate(fields)
if err:
raise ValueError(err)
import uuid as _uuid
cid = _uuid.uuid4().hex[:12]
if not _ID_RE.match(cid):
raise ValueError("внутренняя ошибка id")
rec = {
"id": cid,
"port": DEFAULT_PORTS[norm["protocol"]],
"group": "",
"params": {},
"auth_ref": "",
"cred_ref": "",
"owners": [actor] if actor else [],
"created_by": actor or "",
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime()),
}
rec.update(norm)
conns = self.load()
conns[cid] = rec
self.save(conns)
return {"ok": True, "id": cid}
def update(self, cid, patch):
if not cid or not _ID_RE.match(str(cid)):
raise ValueError("плохой id")
norm, err = self.validate(patch or {}, partial=True)
if err:
raise ValueError(err)
conns = self.load()
rec = conns.get(cid)
if not isinstance(rec, dict):
raise ValueError("соединение не найдено: %s" % cid)
rec.update(norm)
rec["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime())
conns[cid] = rec
self.save(conns)
return {"ok": True}
def delete(self, cid):
if not cid or not _ID_RE.match(str(cid)):
raise ValueError("плохой id")
conns = self.load()
if cid not in conns:
raise ValueError("соединение не найдено: %s" % cid)
del conns[cid]
self.save(conns)
return {"ok": True}
ui_rdp.py 14.7 КБ
"""remotedesk.ui_rdp — /ws/remotedesk/rdp (RDP-сессия в панели).
Протокол WS — как у VNC (ptr/key/keys, clip/paste); сертификат — TOFU.
Маска WS — VNC (1L/2M/4R), RDP ждёт 1L/2R/4M (см. vnc_mask_to_rdp).
"""
WS_PATH = "/ws/remotedesk/rdp"
from botmod_transport_web.ws import make_base as _make_base
BaseSocket = _make_base()
def vnc_mask_to_rdp(mask):
"""VNC-маска (1L/2M/4R) -> RDP (1L/2R/4M)."""
m = int(mask)
return (m & 1) | ((m & 2) << 1) | ((m & 4) >> 1)
def _paste_to_text(pairs):
"""[[flags, codepoint]] -> str для CLIPRDR offer. Кап 128K chars."""
out = []
try:
seq = list(pairs or [])[:131072]
except Exception: # noqa: BLE001
return ""
for item in seq:
# Только пары [flags, cp], остальное игнорим.
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
try:
_flags, cp = item[0], item[1]
cp = int(cp)
except (TypeError, ValueError):
continue
if 0 <= cp <= 0x10FFFF and not (0xD800 <= cp <= 0xDFFF):
try:
out.append(chr(cp))
except (ValueError, OverflowError):
pass
return "".join(out)
class WSSocket(BaseSocket):
RIGHT = "remotedesk_connect"
def on_authed(self, sess):
import tornado.ioloop
self.ws_uid = sess.get("uid")
self.ws_key = None
self.ws_sess = None
self.ws_view_only = False
self.ws_loop = tornado.ioloop.IOLoop.current()
self._stop = False
def _send(self, **kw):
import json as _json
try:
self.write_message(_json.dumps(kw, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
def _login_of(self, msg):
login = msg.get("login")
if not isinstance(login, dict):
return None
out = {}
for k in ("cred_id", "username", "secret", "password"):
v = login.get(k)
if v is not None and str(v) != "":
out[k] = str(v)[:8192]
return out or None
def _open_rdp(self, conn_id, fps=10, view_only=False, pin="",
login=None):
from . import session as _sess
from .rdp import client as _rdp
try:
rec, username, secret = self.mod_ctx.api.call(
"remotedesk.auth", conn_id, login)
except ValueError as e:
return str(e)[:200]
except Exception as e: # noqa: BLE001
return "внутренняя ошибка: %s" % str(e)[:120]
if rec.get("protocol") != "rdp":
return "не rdp-соединение"
owners = rec.get("owners") or []
if owners and ("web:%s" % self.ws_uid) not in owners:
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % self.ws_uid,
"remotedesk.rdp_denied",
conn_id, "", False)
except Exception: # noqa: BLE001
pass
return "нет доступа (owners)"
params = dict(rec.get("params") or {})
try:
timeout = 10
try:
timeout = max(2, min(60, int(
(self.mod_ctx.module_config(
"remotedesk").get("CONNECT_TIMEOUT", "10") or 10))))
except Exception: # noqa: BLE001
pass
# Clamp геометрии: без него большой фреймбуфер сожрёт память.
width = max(640, min(1920,
int(params.get("width", 1024) or 1024)))
height = max(480, min(1200,
int(params.get("height", 768) or 768)))
bpp = int(params.get("bpp", 32) or 32)
bpp = 32 if bpp not in (16, 24, 32) else bpp
sess = _rdp.connect(
rec.get("host", ""), int(rec.get("port") or 3389),
username, secret, params.get("domain", ""),
params.get("security", "any"),
width, height, bpp,
timeout, pin or params.get("cert_pin", ""),
params.get("ignore_cert", False),
max(1, min(15, int(fps or 10))),
params.get("compat", "modern"))
except _rdp.CertPinNeeded as e:
self.ws_pending_conn = conn_id
self.ws_pending_login = login
self._send(need_pin=e.fp,
info="Новый сертификат RDP. Проверьте отпечаток и "
"подтвердите: {\"pin\": \"%s\"}" % e.fp)
return ""
except Exception as e: # noqa: BLE001
return str(e)[:200]
if pin:
# Сертификат подтверждён — сохранить пин (один раз).
try:
self.mod_ctx.api.call("remotedesk.conn_pin_save",
conn_id, "cert_pin", pin)
except Exception: # noqa: BLE001
pass
key, err = _sess.open_session(self.mod_ctx, self.ws_uid, conn_id,
sess, None)
if err:
sess.stop()
return err
self.ws_key = key
self.ws_sess = sess
self.ws_view_only = bool(view_only)
self.ws_last_ptr = 0.0
self.ws_last_btn = 0
try:
sess.sync() # SYNC event первым (как FreeRDP)
except Exception: # noqa: BLE001
pass
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % self.ws_uid,
"remotedesk.rdp_open",
"%s (%s)" % (rec.get("name"), conn_id),
"")
except Exception: # noqa: BLE001
pass
self._send(init={"width": sess.width, "height": sess.height,
"name": rec.get("name", ""),
"security": sess.selected})
import threading as _th
_th.Thread(target=self._sender, daemon=True).start()
return ""
def _sender(self):
import base64 as _b64
import time as _time
from . import session as _sess
try:
idle = 0
while not getattr(self, "_stop", False):
sess = self.ws_sess
if sess is None:
break
try:
dirty = sess.frame.take_dirty()
except Exception: # noqa: BLE001
break
# CLIPRDR-входящие забираем каждую итерацию (независимо от кадров).
try:
take = getattr(sess, "clip_take_inbox", None)
if take is not None:
for text in take() or []:
self.ws_loop.add_callback(self._send,
clip=text)
except Exception: # noqa: BLE001
pass
if not dirty:
# Нет кадров — короткий сон (poll нового кадра).
_time.sleep(0.02)
idle += 1
# Pointer-апдейты шлём и в простое (курсор на статике).
try:
ptr = getattr(getattr(sess, "_fp", None),
"pointer", None)
if ptr and ptr != getattr(
self, "ws_last_sent_ptr", None):
self.ws_last_sent_ptr = dict(ptr)
self.ws_loop.add_callback(self._send,
cursor=ptr)
except Exception: # noqa: BLE001
pass
continue
idle = 0
for (x, y, w, h) in dirty:
try:
png = sess.frame.crop_png(x, y, w, h)
except Exception: # noqa: BLE001
continue
self.ws_loop.add_callback(
self._send, img=_b64.b64encode(png).decode(),
x=x, y=y, w=w, h=h)
_sess.touch(self.ws_key or "")
# Пауза только при малом числе dirty.
if len(dirty) < 3:
_time.sleep(0.01)
except Exception: # noqa: BLE001
pass
def on_message(self, raw):
import json as _json
from .rdp import orders as _o
try:
msg = _json.loads(raw) if isinstance(raw, str) else {}
except Exception: # noqa: BLE001
return
if not isinstance(msg, dict):
return
if msg.get("conn") and not self.ws_sess:
err = self._open_rdp(
str(msg.get("conn") or ""),
msg.get("fps") or 10,
msg.get("view_only") or False,
login=self._login_of(msg))
if err:
import logging as _logging
_logging.getLogger("botmod_remotedesk.ws").warning(
"rdp open %s: %s", msg.get("conn"), err)
self._send(error=err)
return
if msg.get("pin") and not self.ws_sess:
# TOFU: переподключение с пином (conn_id из pending).
conn_id = getattr(self, "ws_pending_conn", "")
if not conn_id:
self._send(error="нет ожидающего подключения")
return
err = self._open_rdp(conn_id, 10, False,
pin=str(msg["pin"]),
login=getattr(
self, "ws_pending_login", None))
if err:
import logging as _logging
_logging.getLogger("botmod_remotedesk.ws").warning(
"rdp open %s (pin): %s", conn_id, err)
self._send(error=err)
return
sess = self.ws_sess
if sess is None:
return
try:
import time as _time
now = _time.monotonic()
if msg.get("ptr") is not None and not self.ws_view_only:
x, y, b, wh = (list(msg["ptr"]) + [0, 0, 0, 0])[:4]
# VNC-маска -> RDP: меняем местами биты средней/правой.
b = vnc_mask_to_rdp(b)
w = int(wh)
# Rate-limit только чистых движений; press/release и wheel — всегда.
if not w and b == getattr(self, "ws_last_btn", 0):
if now - getattr(self, "ws_last_ptr", 0.0) < 0.033:
pass
else:
self.ws_last_ptr = now
sess.pointer(int(x), int(y), b, 0)
else:
self.ws_last_btn = b
self.ws_last_ptr = now
sess.pointer(int(x), int(y), b, w)
if msg.get("key") is not None and not self.ws_view_only:
# Ключи без rate-limit (дроп рвёт середины комбинаций).
code, down = (list(msg["key"]) + ["", True])[:2]
sc = _o.DOM_TO_SCANCODE.get(str(code))
if sc:
sess.key(sc[0], bool(down), bool(sc[1]))
if msg.get("keys") is not None and not self.ws_view_only:
# Атомарное комбо — все события одной INPUT PDU.
evs = []
seq = msg["keys"]
if isinstance(seq, list):
for item in seq[:32]:
try:
code, down = (list(item) + ["", True])[:2]
except Exception: # noqa: BLE001
continue
sc = _o.DOM_TO_SCANCODE.get(str(code))
if sc:
evs.append(_o.slowpath_key(
sc[0], bool(down), bool(sc[1])))
if evs:
sess.combo(evs)
if msg.get("paste") is not None and not self.ws_view_only:
# Вставка: сначала CLIPRDR-канал, без канала — paste_unicode.
try:
text = _paste_to_text(msg["paste"])
if text and sess.clip_offer(text):
import logging as _logging
_logging.getLogger(
"botmod_remotedesk.ws").info(
"rdp clip offer %d chars", len(text))
else:
sess.paste_unicode(msg["paste"])
except Exception: # noqa: BLE001
pass
if msg.get("clip") is not None and not self.ws_view_only:
# Тот же путь строкой {clip}: CLIPRDR, без канала — эмуляция набора.
try:
text = str(msg["clip"] or "")[:131072]
if text and not sess.clip_offer(text):
sess.paste_unicode(
[[0x4000, ord(c)] for c in text[:512]])
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001
pass
def on_close(self):
from . import session as _sess
try:
self._stop = True
except Exception: # noqa: BLE001
pass
try:
if self.ws_sess is not None:
self.ws_sess.stop()
except Exception: # noqa: BLE001
pass
self.ws_sess = None
if getattr(self, "ws_key", None):
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % getattr(self, "ws_uid", "?"),
"remotedesk.rdp_close",
self.ws_key, "")
except Exception: # noqa: BLE001
pass
_sess.close_session(self.ws_key)
self.ws_key = None
try:
base_close = super().on_close
except Exception: # noqa: BLE001
base_close = None
if base_close:
try:
base_close()
except Exception: # noqa: BLE001
pass
ui_vnc.py 8.7 КБ
"""remotedesk.ui_vnc — /ws/remotedesk/vnc (VNC-сессия в панели).
WS: {"conn"} + ввод ptr/key/keys/cut; сервер шлёт init/img/error/info.
Маска кнопок: бит0 L, бит1 M, бит2 R; wheel — кнопки 4/5 press+release.
"""
WS_PATH = "/ws/remotedesk/vnc"
from botmod_transport_web.ws import make_base as _make_base
BaseSocket = _make_base()
class WSSocket(BaseSocket):
RIGHT = "remotedesk_connect"
def on_authed(self, sess):
import tornado.ioloop
self.ws_uid = sess.get("uid")
self.ws_key = None
self.ws_sess = None
self.ws_view_only = False
self.ws_loop = tornado.ioloop.IOLoop.current()
self._stop = False
def _send(self, **kw):
import json as _json
try:
self.write_message(_json.dumps(kw, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
def _login_of(self, msg):
login = msg.get("login")
if not isinstance(login, dict):
return None
out = {}
for k in ("cred_id", "username", "secret", "password"):
v = login.get(k)
if v is not None and str(v) != "":
out[k] = str(v)[:8192]
return out or None
def _open_vnc(self, conn_id, fps=10, view_only=False, login=None):
from . import session as _sess
from .rfb import client as _vnc
try:
rec, _user, secret = self.mod_ctx.api.call(
"remotedesk.auth", conn_id, login)
except ValueError as e:
return str(e)[:200]
except Exception as e: # noqa: BLE001
return "внутренняя ошибка: %s" % str(e)[:120]
if rec.get("protocol") != "vnc":
return "не vnc-соединение"
owners = rec.get("owners") or []
if owners and ("web:%s" % self.ws_uid) not in owners:
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % self.ws_uid,
"remotedesk.vnc_denied",
conn_id, "", False)
except Exception: # noqa: BLE001
pass
return "нет доступа (owners)"
params = dict(rec.get("params") or {})
try:
timeout = 10
try:
timeout = max(2, min(60, int(
(self.mod_ctx.module_config(
"remotedesk").get("CONNECT_TIMEOUT", "10") or 10))))
except Exception: # noqa: BLE001
pass
sess = _vnc.connect(rec.get("host", ""),
int(rec.get("port") or 5900),
secret, timeout,
shared=params.get("shared", True),
fps=max(1, min(15, int(fps or 10))))
except Exception as e: # noqa: BLE001
return str(e)[:200]
key, err = _sess.open_session(self.mod_ctx, self.ws_uid, conn_id,
sess, None)
if err:
sess.stop()
return err
self.ws_key = key
self.ws_sess = sess
self.ws_view_only = bool(view_only) or \
(params.get("read_only") in (True, "1", "true", "yes"))
self.ws_last_ptr = 0.0
self.ws_last_btn = 0
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % self.ws_uid,
"remotedesk.vnc_open",
"%s (%s)" % (rec.get("name"), conn_id),
"")
except Exception: # noqa: BLE001
pass
self._send(init={"width": sess.width, "height": sess.height,
"name": sess.info.get("name", "")})
import threading as _th
_th.Thread(target=self._sender, daemon=True).start()
return ""
def _sender(self):
import base64 as _b64
import time as _time
from . import session as _sess
try:
while not getattr(self, "_stop", False):
sess = self.ws_sess
if sess is None:
break
try:
dirty = sess.frame.take_dirty()
except Exception: # noqa: BLE001
break
for (x, y, w, h) in dirty:
try:
png = sess.frame.crop_png(x, y, w, h)
except Exception: # noqa: BLE001
continue
self.ws_loop.add_callback(
self._send, img=_b64.b64encode(png).decode(),
x=x, y=y, w=w, h=h)
_sess.touch(self.ws_key or "")
_time.sleep(0.1)
except Exception: # noqa: BLE001
pass
def on_message(self, raw):
import json as _json
try:
msg = _json.loads(raw) if isinstance(raw, str) else {}
except Exception: # noqa: BLE001
return
if not isinstance(msg, dict):
return
if msg.get("conn") and not self.ws_sess:
err = self._open_vnc(
str(msg.get("conn") or ""),
msg.get("fps") or 10,
msg.get("view_only") or False,
self._login_of(msg))
if err:
import logging as _logging
_logging.getLogger("botmod_remotedesk.ws").warning(
"vnc open %s: %s", msg.get("conn"), err)
self._send(error=err)
return
sess = self.ws_sess
if sess is None:
return
try:
import time as _time
now = _time.monotonic()
if msg.get("ptr") is not None and not self.ws_view_only:
x, y, b, wh = (list(msg["ptr"]) + [0, 0, 0, 0])[:4]
b, w = int(b), int(wh)
if w:
# Wheel: RFB кнопки 4/5 (press+release).
sess.pointer(int(x), int(y), b)
sess.pointer(int(x), int(y),
b | (4 if w > 0 else 2))
sess.pointer(int(x), int(y), b)
self.ws_last_btn = b
self.ws_last_ptr = now
elif b == getattr(self, "ws_last_btn", 0):
# Throttle чистых движений (паритет с ui_rdp).
if now - getattr(self, "ws_last_ptr", 0.0) < 0.033:
pass
else:
self.ws_last_ptr = now
sess.pointer(int(x), int(y), b)
else:
self.ws_last_btn = b
self.ws_last_ptr = now
sess.pointer(int(x), int(y), b)
if msg.get("key") is not None and not self.ws_view_only:
ks, down = (list(msg["key"]) + [0, True])[:2]
sess.key(int(ks), bool(down))
if msg.get("keys") is not None and not self.ws_view_only:
# Атомарное комбо [[keysym, down], ...] пачкой (паритет с rdp).
seq = msg["keys"]
if isinstance(seq, list):
for item in seq[:32]:
try:
ks, down = (list(item) + [0, True])[:2]
except Exception: # noqa: BLE001
continue
sess.key(int(ks), bool(down))
if msg.get("cut") is not None and not self.ws_view_only:
sess.cut(str(msg["cut"])[:65536])
except Exception: # noqa: BLE001
pass
def on_close(self):
from . import session as _sess
try:
self._stop = True
except Exception: # noqa: BLE001
pass
try:
if self.ws_sess is not None:
self.ws_sess.stop()
except Exception: # noqa: BLE001
pass
self.ws_sess = None
if getattr(self, "ws_key", None):
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % getattr(self, "ws_uid", "?"),
"remotedesk.vnc_close",
self.ws_key, "")
except Exception: # noqa: BLE001
pass
_sess.close_session(self.ws_key)
self.ws_key = None
try:
base_close = super().on_close
except Exception: # noqa: BLE001
base_close = None
if base_close:
try:
base_close()
except Exception: # noqa: BLE001
pass
ui_web.py 9.8 КБ
"""remotedesk.ui_web — HTTP API кабинета «Удалённый доступ»."""
ROUTES = [
("GET", "/api/remotedesk/conns", "conns", {"right": "remotedesk_connect",
"desc": "Список соединений"}),
("POST", "/api/remotedesk/conn", "conn", {"right": "remotedesk_manage",
"desc": "Добавить/обновить соединение {id?, name, protocol, host, port?, group?, params?, auth_ref?}"}),
("POST", "/api/remotedesk/conn_delete", "conn_delete", {"right": "remotedesk_manage",
"desc": "Удалить соединение {id}"}),
("POST", "/api/remotedesk/conn_test", "conn_test", {"right": "remotedesk_connect",
"desc": "TCP-пробник соединения {id}"}),
("GET", "/api/remotedesk/creds", "creds", {"right": "remotedesk_connect",
"desc": "Список кредов (без секретов)"}),
("POST", "/api/remotedesk/cred", "cred", {"right": "remotedesk_manage",
"desc": "Добавить/обновить кред {id?, name, username?, kind?, secret?, note?}"}),
("POST", "/api/remotedesk/cred_delete", "cred_delete", {"right": "remotedesk_manage",
"desc": "Удалить кред {id}"}),
# Файлы — только POST: GET светил бы пути и содержимое в логах,
# истории браузера и прокси-кэшах.
("POST", "/api/remotedesk/files_list", "files_list_p", {"right": "remotedesk_files",
"desc": "SFTP-каталог {id, path?, login?} (login — ручной ввод, в теле)"}),
("POST", "/api/remotedesk/files_read", "files_read_p", {"right": "remotedesk_files",
"desc": "Удалённый файл {id, path, login?}"}),
("POST", "/api/remotedesk/files_write", "files_write", {"right": "remotedesk_files",
"desc": "Записать удалённый файл {id, path, text, binary?} (binary — base64)"}),
("POST", "/api/remotedesk/files_mkdir", "files_mkdir", {"right": "remotedesk_files",
"desc": "Создать удалённый каталог {id, path}"}),
("POST", "/api/remotedesk/files_delete", "files_delete", {"right": "remotedesk_files",
"desc": "Удалить удалённый файл {id, path, recursive?}"}),
("POST", "/api/remotedesk/files_rename", "files_rename", {"right": "remotedesk_files",
"desc": "Переименовать {id, src, dst}"}),
("POST", "/api/remotedesk/files_chmod", "files_chmod", {"right": "remotedesk_files",
"desc": "Права {id, path, mode} (octal 755)"}),
("POST", "/api/remotedesk/files_chown", "files_chown", {"right": "remotedesk_files",
"desc": "Владелец {id, path, uid?, gid?}"}),
("POST", "/api/remotedesk/files_zip", "files_zip", {"right": "remotedesk_files",
"desc": "Скачать дерево ZIP {id, path} (base64)"}),
]
SERVICE = [
{"key": "remotedesk_connect", "icon": "🖥️", "title": "Удалённый доступ",
"right": "remotedesk_connect", "phase": 4,
"desc": "SSH/VNC/RDP в браузере + файловый менеджер (нативный Python)"},
]
PANELS = []
def _actor(req):
sess = req.get("session") or {}
return "web:%s" % sess.get("uid") if sess.get("uid") else "?"
def _login_of(body):
"""login из тела: dict — ок, отсутствует — None, мусор — 400."""
login = body.get("login")
if login is None:
return None
if not isinstance(login, dict):
from core.errors import UserError
raise UserError("плохой login: нужен объект")
return login
def handle_api(ctx, config, method, req):
from core.errors import UserError
if method == "conns":
return ctx.api.call("remotedesk.conn_list")
if method == "conn":
body = req.get("body") or {}
try:
if body.get("id"):
patch = {k: v for k, v in body.items() if k != "id"}
return ctx.api.call("remotedesk.conn_update",
body.get("id"), patch, _actor(req))
return ctx.api.call("remotedesk.conn_add", body, _actor(req))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "conn_delete":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.conn_delete",
body.get("id") or "", _actor(req))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "conn_test":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.conn_test",
body.get("id") or "")
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "creds":
return ctx.api.call("remotedesk.cred_list")
if method == "cred":
body = req.get("body") or {}
# Секрет — только в теле POST (никогда в URL).
try:
if body.get("id"):
return ctx.api.call("remotedesk.cred_update",
body.get("id"), body, _actor(req))
return ctx.api.call("remotedesk.cred_add", body, _actor(req))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "cred_delete":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.cred_delete",
body.get("id") or "", _actor(req))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_list_p":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_list",
body.get("id") or "",
body.get("path") or "", _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_read_p":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_read",
body.get("id") or "",
body.get("path") or "", _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_write":
body = req.get("body") or {}
if "text" in body and not isinstance(body.get("text"), str):
return 400, {"ok": False,
"error": "плохой text: нужен str"}
try:
return ctx.api.call("remotedesk.files_write",
cid=body.get("id") or "",
path=body.get("path") or "",
text=body.get("text") or "",
actor=_actor(req),
login=_login_of(body),
binary=bool(body.get("binary")))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_mkdir":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_mkdir",
body.get("id") or "",
body.get("path") or "", _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_delete":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_delete",
body.get("id") or "",
body.get("path") or "",
bool(body.get("recursive")), _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_rename":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_rename",
body.get("id") or "",
body.get("src") or "",
body.get("dst") or "", _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_chmod":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_chmod",
body.get("id") or "",
body.get("path") or "",
body.get("mode") or "", _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_chown":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_chown",
body.get("id") or "",
body.get("path") or "",
body.get("uid"), body.get("gid"),
_actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
if method == "files_zip":
body = req.get("body") or {}
try:
return ctx.api.call("remotedesk.files_zip",
body.get("id") or "",
body.get("path") or "", _actor(req),
_login_of(body))
except ValueError as e:
return 400, {"ok": False, "error": str(e)}
raise UserError("неизвестный метод: %s" % method)
ui_ws.py 9.7 КБ
"""remotedesk.ui_ws — /ws/remotedesk/ssh (SSH-терминал в панели).
Протокол: первое {"conn", cols, rows} (+login/pin), дальше {"data"}/resize.
VNC/RDP-сокеты — в ui_vnc.py/ui_rdp.py через WS_EXTRA.
"""
WS_PATH = "/ws/remotedesk/ssh"
from botmod_transport_web.ws import make_base as _make_base
BaseSocket = _make_base()
def _extra_sockets():
"""[(path, cls)] VNC/RDP. Ошибка импорта — только SSH, с варнингом."""
import logging as _logging
try:
from . import ui_vnc as _vnc
from . import ui_rdp as _rdp
return [(_vnc.WS_PATH, _vnc.WSSocket),
(_rdp.WS_PATH, _rdp.WSSocket)]
except Exception as e: # noqa: BLE001
_logging.getLogger(__name__).warning(
"remotedesk: VNC/RDP-сокеты не загружены: %s", e)
return []
WS_EXTRA = _extra_sockets()
class WSSocket(BaseSocket):
RIGHT = "remotedesk_connect"
def on_authed(self, sess):
import tornado.ioloop
self.ws_uid = sess.get("uid")
self.ws_key = None
self.ws_login = None # login-оверрайд первого сообщения
self.ws_loop = tornado.ioloop.IOLoop.current()
self.ws_pending = None # (conn_id, cols, rows, login) после need_pin
self._stop = False
def _send(self, **kw):
import json as _json
try:
self.write_message(_json.dumps(kw, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
def _login_of(self, msg):
login = msg.get("login")
if not isinstance(login, dict):
return None
out = {}
for k in ("cred_id", "username", "secret", "password"):
v = login.get(k)
if v is not None and str(v) != "":
out[k] = str(v)[:8192]
return out or None
def _open_ssh(self, conn_id, cols, rows, pin="", login=None):
from . import session as _sess
from . import ssh_client as _ssh
ctx = self.mod_ctx
try:
rec, username, secret = ctx.api.call(
"remotedesk.auth", conn_id, login)
except ValueError as e:
return str(e)[:200]
except Exception as e: # noqa: BLE001
return "внутренняя ошибка: %s" % str(e)[:120]
if rec.get("protocol") != "ssh":
return "не ssh-соединение"
owners = rec.get("owners") or []
if owners and ("web:%s" % self.ws_uid) not in owners:
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % self.ws_uid,
"remotedesk.ssh_denied",
conn_id, "", False)
except Exception: # noqa: BLE001
pass
return "нет доступа (owners)"
if not secret:
return ("нет секрета: выбери кред из списка или введи "
"пароль вручную (кнопка «Логин»)")
params = dict(rec.get("params") or {})
if pin:
params["host_key_pin"] = pin
try:
timeout = 10
try:
timeout = max(2, min(60, int(
(self.mod_ctx.module_config(
"remotedesk").get("CONNECT_TIMEOUT", "10") or 10))))
except Exception: # noqa: BLE001
pass
client = _ssh.connect(
rec.get("host", ""), int(rec.get("port") or 22),
username, secret, params, timeout)
except _ssh.NeedPin as e:
self.ws_pending = (conn_id, cols, rows, login)
self._send(need_pin=e.fp, key_hint=(e.key_b64 or "")[:64],
info="Новый host-key. Проверьте отпечаток и "
"подтвердите: {\"pin\": \"%s\"}" % e.fp)
return ""
except ValueError as e:
return str(e)[:200]
if pin:
# TOFU подтверждён — сохранить пин.
try:
ctx.api.call("remotedesk.conn_pin_save", conn_id,
"host_key_pin", pin)
except Exception: # noqa: BLE001
pass
try:
ch = _ssh.open_shell(client, cols, rows)
except ValueError as e:
try:
client.close()
except Exception: # noqa: BLE001
pass
return str(e)[:200]
key, err = _sess.open_session(ctx, self.ws_uid, conn_id,
client, ch, cols, rows)
if err:
try:
ch.close()
except Exception: # noqa: BLE001
pass
try:
client.close()
except Exception: # noqa: BLE001
pass
return err
self.ws_key = key
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % self.ws_uid,
"remotedesk.ssh_open",
"%s (%s)" % (rec.get("name"), conn_id), "")
except Exception: # noqa: BLE001
pass
import threading as _th
_th.Thread(target=self._reader, daemon=True).start()
self._send(info="подключено: %s" % rec.get("name", conn_id))
return ""
def _reader(self):
import logging as _logging
logger = _logging.getLogger("botmod_remotedesk.ws")
from . import session as _sess
try:
while not getattr(self, "_stop", False):
rec = _sess.get(self.ws_key or "")
ch = (rec or {}).get("channel")
if ch is None:
break
try:
if ch.exit_status_ready():
self.ws_loop.add_callback(
self._send, info="[сессия завершена]")
try:
self.close()
except Exception: # noqa: BLE001
pass
break
if ch.recv_ready():
data = ch.recv(65536)
if not data:
break
try:
text = data.decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
text = ""
if text:
self.ws_loop.add_callback(self._send, data=text)
_sess.touch(self.ws_key or "")
else:
import time as _time
_time.sleep(0.05)
except Exception as e: # noqa: BLE001
logger.debug("ssh reader %s: %s", self.ws_key, e)
break
finally:
pass
def on_message(self, raw):
import json as _json
from . import session as _sess
try:
msg = _json.loads(raw) if isinstance(raw, str) else {}
except Exception: # noqa: BLE001
return
if not isinstance(msg, dict):
return
if msg.get("conn") and not self.ws_key:
login = self._login_of(msg)
self.ws_login = login
err = self._open_ssh(
str(msg.get("conn") or ""),
max(20, min(400, int(msg.get("cols") or 80))),
max(5, min(200, int(msg.get("rows") or 24))),
login=login)
if err:
import logging as _logging
_logging.getLogger("botmod_remotedesk.ws").warning(
"ssh open %s: %s", msg.get("conn"), err)
self._send(error=err)
return
if msg.get("pin") and self.ws_pending and not self.ws_key:
conn_id, cols, rows, login = self.ws_pending
self.ws_pending = None
err = self._open_ssh(conn_id, cols, rows,
pin=str(msg["pin"]), login=login)
if err:
self._send(error=err)
return
rec = _sess.get(self.ws_key or "")
ch = (rec or {}).get("channel")
if ch is None:
return
try:
if msg.get("data") is not None:
ch.send(str(msg.get("data") or ""))
_sess.touch(self.ws_key or "")
if msg.get("cols") and msg.get("rows"):
try:
ch.resize_pty(
max(20, min(400, int(msg["cols"]))),
max(5, min(200, int(msg["rows"]))))
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001
pass
def on_close(self):
from . import session as _sess
try:
self._stop = True
except Exception: # noqa: BLE001
pass
if getattr(self, "ws_key", None):
try:
self.mod_ctx.api.call("audit.record",
"web:%s" % getattr(self, "ws_uid", "?"),
"remotedesk.ssh_close",
self.ws_key, "")
except Exception: # noqa: BLE001
pass
_sess.close_session(self.ws_key)
self.ws_key = None
try:
base_close = super().on_close
except Exception: # noqa: BLE001
base_close = None
if base_close:
try:
base_close()
except Exception: # noqa: BLE001
pass