Версия документации: 0.5 (март 2026)
Статус: Актуально для ветки dev
Этот документ описывает API и практики разработки пользовательских команд для PVE SSH Server.
Команды позволяют:
- выполнять действия от имени пользователя через SSH;
- управлять окружением и терминалом;
- запускать интерактивные процессы;
- работать с базой данных;
- реализовывать собственную бизнес-логику и контроль доступа.
Документация ориентирована на разработчиков, которые добавляют новые команды в директорию commands/.
commands/
├── internal/ # Группа (категория) команд
│ ├── __init__.py # Метаданные категории
│ ├── about.py
│ ├── echo.py
│ ├── export.py
│ ├── sessions.py
│ └── whoami.py
├── pve/ # Категория для Proxmox VE
│ ├── __init__.py
│ └── bash.py
├── test/ # Тестовые команды
│ ├── __init__.py
│ ├── char.py
│ ├── color.py
│ └── mouse.py
└── __init__.py # (опционально) корневая категория
-
Каждая папка = категория (group).
-
Команда может быть:
- Single-file (
*.py) - Command Module (папка с
__init__.pyи"type": "command")
- Single-file (
# commands/internal/about.py
from sshserver.session.manager import get_current_session
async def execute(username: str, *args) -> str | None:
session = get_current_session()
return f"Hello, {username}! Your session UUID: {session.uuid}\n"
command = {
"name": "about",
"help": "Show information about server and session",
"func": execute,
}# commands/edit/__init__.py
async def execute(username: str, *args):
...
command = {
"type": "command",
"name": "edit",
"help": "Edit configuration",
"func": execute,
"permissions": ["config_edit"]
}Используйте такой формат, если команда:
- большая;
- имеет несколько вспомогательных файлов;
- работает с API, шаблонами или сложной логикой.
# commands/internal/__init__.py
command = {
"type": "category",
"name": "internal",
"help": "Internal server management commands",
"permissions": []
}Если
__init__.pyв папке отсутствует или не содержитcommand = {...}, папка считается обычной категорией без дополнительных прав.
Права наследуются вниз по дереву:
commands/ → internal/ → edit/ → config.py
- Команда наследует все права родительских категорий.
- Если в команде указаны свои
permissions, они объединяются с наследованными. - Если итоговый список прав пустой — команда доступна всем пользователям.
# internal/__init__.py
"permissions": ["internal_access"]
# edit/__init__.py
"permissions": ["config_edit"]
# edit/config.py
"permissions": ["config_write"]Итоговые права:
["internal_access", "config_edit", "config_write"]Проверка доступа к самой команде выполняется автоматически в CommandDispatcher.
После авторизации сервер загружает права пользователя и помещает их в текущую сессию.
from sshserver.session.manager import get_current_session
session = get_current_session()
permissions = session.extra["permissions"]session.extra["permissions"] — это уже вычисленный список разрешений, доступных текущему пользователю.
Обычно права определяются:
- по
group_idпользователя; - по конфигурации ролей/групп;
- администратором сервера.
Команда не обязана знать внутренний механизм расчёта — ей достаточно использовать уже готовый список.
Иногда доступа к самой команде недостаточно. Нужна дополнительная проверка внутри логики.
Типичные примеры:
- пользователь может редактировать только свои SSH-ключи;
- менять группу другим пользователям может только администратор;
- удаление требует отдельного разрешения.
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
perms = set(session.extra["permissions"])
target_user = args[0] if args else username
if target_user != username and "users_manage" not in perms:
return "Access denied: you can modify only your own account.\n"
return f"Allowed for target: {target_user}\n"- Доступ к команде как сущности → через
command["permissions"] - Тонкая бизнес-логика → через
session.extra["permissions"]
Ниже — рекомендуемый набор импортов для большинства команд.
import json
import logging
import argparse
from sshserver.session.manager import get_current_session
from sshserver.terminal import Terminal
from helpers.globals import GlobalStoresession.extra — это словарь служебных объектов и данных, доступных команде.
{
"terminal": Terminal,
"env": UserEnvironment,
"permissions": list[str],
...
}session = get_current_session()terminal: Terminal = session.extra["terminal"]env = session.extra["env"]permissions = session.extra["permissions"]GlobalStore — контейнер глобальных сервисов приложения.
store = GlobalStore.get()db = store.require("db")config = store.get("config")В зависимости от инициализации сервера:
db— база данныхconfig— конфигурацияcache— кэш- другие сервисы, зарегистрированные приложением
Гарантированно используйте только те сервисы, которые действительно инициализируются в вашем runtime.
"""
Короткое описание команды.
"""
import logging
from sshserver.session.manager import get_current_session
from sshserver.terminal import Terminal
logger = logging.getLogger(__name__)
async def execute(username: str, *args) -> str | None:
"""
username — имя пользователя
*args — аргументы после имени команды
"""
session = get_current_session()
terminal: Terminal = session.extra["terminal"]
env = session.extra["env"]
logger.info("Command mycommand executed by %s", username)
env.set("CUSTOM_VAR", "value")
return "Command executed successfully.\n"
command = {
"name": "mycommand",
"help": "One-line description for help",
"func": execute,
}Команда может быть реализована как:
async def execute(...)def execute(...)
- работает с БД;
- пишет в терминал через async API;
- читает пользовательский ввод;
- использует PTY;
- делает сетевые вызовы;
- работает с асинхронными сервисами.
- очень простая;
- не использует async API;
- просто возвращает текст.
Для новых команд предпочтительно использовать async def.
Если синхронные команды внутри текущего диспетчера выполняются через отдельную обёртку или поток, это зависит от реализации. Не полагайтесь на это без проверки.
Команда может возвращать:
strbytesNonelist[str | bytes]
Не полагайтесь на автоматическое добавление \n.
Если вывод должен завершаться новой строкой — добавляйте её вручную.
return "Done.\n"return "Done."Во втором случае следующий shell prompt или вывод другой команды может “прилипнуть” к тексту.
Для команд с флагами и подкомандами рекомендуется использовать argparse, но безопасно.
Стандартный ArgumentParser при ошибке вызывает sys.exit(), что нежелательно внутри команды.
import argparse
class SafeArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise ValueError(message)
def exit(self, status=0, message=None):
raise ValueError(message or "Argument parsing failed")
def build_parser():
parser = SafeArgumentParser(prog="mycmd", add_help=True)
parser.add_argument("--force", action="store_true", help="Force operation")
parser.add_argument("target", nargs="?")
return parserasync def execute(username: str, *args):
parser = build_parser()
try:
ns, unknown = parser.parse_known_args(args)
except ValueError as e:
return f"Argument error: {e}\n"
if unknown:
return f"Unknown arguments: {' '.join(unknown)}\n"
return f"Target={ns.target}, force={ns.force}\n"- не убивает процесс;
- позволяет вручную обработать лишние аргументы;
- удобен для SSH CLI-команд.
Это рекомендуемый паттерн для команд вида:
sshkey addsshkey listsshkey deleteuser createuser deletevm startvm stop
import argparse
class SafeArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise ValueError(message)
def exit(self, status=0, message=None):
raise ValueError(message or "Argument parsing failed")
def build_parser():
parser = SafeArgumentParser(prog="sshkey")
sub = parser.add_subparsers(dest="action", required=True)
sub.add_parser("list", help="List SSH keys")
p_add = sub.add_parser("add", help="Add SSH key")
p_add.add_argument("key", help="Public SSH key")
p_delete = sub.add_parser("delete", help="Delete SSH key by index")
p_delete.add_argument("index", type=int)
return parserasync def execute(username: str, *args):
parser = build_parser()
try:
ns, unknown = parser.parse_known_args(args)
except ValueError as e:
return f"Argument error: {e}\n"
if unknown:
return f"Unknown arguments: {' '.join(unknown)}\n"
if ns.action == "list":
return "Listing keys...\n"
if ns.action == "add":
return f"Adding key: {ns.key[:32]}...\n"
if ns.action == "delete":
return f"Deleting key #{ns.index}\n"
return "Unknown action.\n"Доступ:
session = get_current_session()
env = session.extra["env"]env.set("PS1", "pve> ")
env.set("EDITOR", "nano")
value = env.get("USER")
env.unset("TEMP_VAR")
text = env.substitute("Hello $USER, your TERM is $TERM")env.export("VAR=value")env.substitute(text)env.as_dict()
Важно понимать:
- изменения через
env.set(...)живут только в текущей SSH-сессии; - они не сохраняются автоматически в БД.
env.set("EDITOR", "vim")Нужно обновить поле saved_env в таблице users.
import json
from helpers.globals import GlobalStore
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
env = session.extra["env"]
db = GlobalStore.get().require("db")
env.set("EDITOR", "vim")
async with db.transaction():
await db.execute(
"UPDATE users SET saved_env = ? WHERE username = ?",
(json.dumps(env.as_dict(), ensure_ascii=False), username)
)
return "EDITOR saved permanently.\n"Вся работа с терминалом пользователя происходит через объект Terminal.
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
terminal = session.extra["terminal"]Функция execute может возвращать:
strbytesNonelist[str | bytes]
async def execute(username: str, *args) -> str | bytes | None:
if not args:
return "Usage: mycmd <argument>\n"
return f"Received: {' '.join(args)}\n"- Для обычного текста используйте
str - Для ANSI/escape-последовательностей допустимы и
str, иbytes - Для сложного вывода — используйте прямую запись в терминал
return "\x1b[31mКрасный текст\x1b[0m\n"await terminal.output.write("Привет, мир!\n")
await terminal.output.write(b"\x1b[2J\x1b[H")
await terminal.output.writelines(["Строка 1\n", "Строка 2\n"])terminal.input— чтение вводаterminal.output— запись выводаterminal.pty— работа с псевдотерминаломterminal.rows/terminal.cols— текущий размер терминала
line = await terminal.input.read_str()
data = await terminal.input.read_bytes(max_bytes=1024, timeout=10.0)write(data: str | bytes)writelines(lines: list[str | bytes])flush()
Для простых подтверждений не нужен полноценный PTY.
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
terminal = session.extra["terminal"]
await terminal.output.write("Delete resource? [y/N]: ")
answer = (await terminal.input.read_str()).strip().lower()
if answer not in ("y", "yes"):
return "Cancelled.\n"
return "Resource deleted.\n"await terminal.output.write("Press any key...\n")
data = await terminal.input.read_bytes(max_bytes=1, timeout=10.0)
if not data:
return "Timeout.\n"
return f"Received byte: {data!r}\n"mouse = terminal.input.mouse
await mouse.enable(1000)
await mouse.enable([1002, 1006])
await mouse.disable()
await mouse.disable(1006)1000— базовые клики1002— drag / motion1006— SGR-формат (рекомендуется)
from sshserver.terminal.mouse_handler import MouseEvent
async def on_mouse_event(event: MouseEvent):
if event.wheel:
print(f"Wheel: {'up' if event.wheel > 0 else 'down'}")
else:
print(f"Button {event.button} {event.state} at ({event.x}, {event.y})")
mouse.add_listener(on_mouse_event)buttonx,ystatewheelmodifiers
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
terminal = session.extra["terminal"]
mouse = terminal.input.mouse
async def on_mouse_event(event):
await terminal.output.write(
f"\rMouse: button={event.button} state={event.state} x={event.x} y={event.y} \n"
)
await mouse.enable([1000, 1006])
mouse.add_listener(on_mouse_event)
try:
await terminal.output.write("Mouse test enabled. Press Enter to exit.\n")
await terminal.input.read_str()
finally:
await mouse.disable()
return "Mouse test finished.\n"Если команда включает мышь, всегда отключайте её в
finally.
terminal.pty используется для интерактивных приложений: shell, vim, htop, консоль ВМ и т.д.
pty = terminal.pty
await pty.ensure()
slave_fd = pty.get_slave_fd()
await pty.resize(rows=24, cols=80)
await pty.attach_streams()
await pty.detach_streams()Объект Terminal предоставляет:
terminal.rows
terminal.colsЭти значения отражают текущий размер SSH-окна пользователя и обновляются при resize.
await pty.resize(terminal.rows, terminal.cols)process = await pty.spawn(
cmd="/bin/bash",
args=["--login"],
env=session.extra["env"].as_dict(),
cwd="/root"
)spawn(...) обычно:
- создаёт PTY;
- назначает управляющий терминал;
- запускает процесс;
- подготавливает окружение.
await pty.attach_streams()Этот вызов блокирует выполнение команды до завершения процесса или разрыва bridge.
То есть:
await pty.attach_streams()
return "Done.\n"вернёт строку только после выхода пользователя из shell / vim / консоли ВМ.
Используйте try/finally.
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
terminal = session.extra["terminal"]
pty = terminal.pty
await pty.ensure()
await pty.resize(terminal.rows, terminal.cols)
try:
await pty.spawn("/bin/bash", ["-i"], env=session.extra["env"].as_dict())
await pty.attach_streams()
finally:
try:
await pty.detach_streams()
except Exception:
pass
return "PTY session finished.\n"Зависит от реализации PTYHandler.
Безопасный практический подход:
- если вы вручную включили bridge, постарайтесь вручную его и завершить;
- cleanup через
finally— предпочтителен.
Для TUI и полноэкранных режимов удобно использовать альтернативный экран:
\x1b[?1049h— включить\x1b[?1049l— выключить
from sshserver.session.manager import get_current_session
async def execute(username: str, *args):
session = get_current_session()
terminal = session.extra["terminal"]
try:
await terminal.output.write(b"\x1b[?1049h")
await terminal.output.write(b"\x1b[2J\x1b[H")
await terminal.output.write("Interactive tool started.\n")
await terminal.output.write("Press Enter to exit.\n")
await terminal.input.read_str()
finally:
await terminal.output.write(b"\x1b[?1049l")
return NoneБаза данных доступна глобально через GlobalStore.
from helpers.globals import GlobalStore
async def execute(username: str, *args):
db = GlobalStore.get().require("db")Все методы асинхронные:
fetch_one(query, params=None)fetch_all(query, params=None)fetch_val(query, params=None)execute(query, params=None)commit()rollback()transaction()
Используйте плейсхолдеры ?.
Если выполняется несколько связанных изменений — используйте транзакцию.
import logging
from helpers.globals import GlobalStore
logger = logging.getLogger(__name__)
async def execute(username: str, *args):
db = GlobalStore.get().require("db")
try:
async with db.transaction():
await db.execute(
"UPDATE users SET group_id = ? WHERE username = ?",
(2, username)
)
await db.execute(
"UPDATE users SET api_key = ? WHERE username = ?",
("new-api-key", username)
)
except Exception as e:
logger.exception("Failed to update user %s", username)
return f"Database error: {e}\n"
return "User updated successfully.\n"- при успехе →
commit - при ошибке →
rollback
Некоторые поля в таблице users хранят JSON внутри TEXT:
ssh_keyssaved_envhistory
Используйте:
json.loads(...)json.dumps(...)
import json
from helpers.globals import GlobalStore
async def execute(username: str, *args):
db = GlobalStore.get().require("db")
raw = await db.fetch_val(
"SELECT ssh_keys FROM users WHERE username = ?",
(username,)
)
ssh_keys = json.loads(raw or "[]")
return f"SSH keys count: {len(ssh_keys)}\n"import json
from helpers.globals import GlobalStore
async def execute(username: str, *args):
db = GlobalStore.get().require("db")
raw = await db.fetch_val(
"SELECT ssh_keys FROM users WHERE username = ?",
(username,)
)
ssh_keys = json.loads(raw or "[]")
ssh_keys.append("ssh-ed25519 AAAAC3Nza... user@host")
async with db.transaction():
await db.execute(
"UPDATE users SET ssh_keys = ? WHERE username = ?",
(json.dumps(ssh_keys, ensure_ascii=False), username)
)
return "SSH key added.\n"json.dumps(data, ensure_ascii=False)CREATE TABLE users (
username TEXT PRIMARY KEY,
api_key TEXT,
api_secret TEXT,
ssh_keys TEXT DEFAULT '[]',
group_id INTEGER DEFAULT 0,
saved_env TEXT DEFAULT '{}',
history TEXT DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)username— имя пользователяapi_key/api_secret— API-учётные данныеssh_keys— JSON-массив публичных SSH-ключейgroup_id— группа / роль пользователяsaved_env— JSON-объект постоянных переменных окруженияhistory— JSON-массив истории командcreated_at— дата создания
Поле history хранит историю команд пользователя.
[
"whoami",
"pve bash 101",
"sshkey list"
]import json
from helpers.globals import GlobalStore
async def execute(username: str, *args):
db = GlobalStore.get().require("db")
raw = await db.fetch_val(
"SELECT history FROM users WHERE username = ?",
(username,)
)
history = json.loads(raw or "[]")
return "\n".join(history[-20:]) + "\n"import json
from helpers.globals import GlobalStore
async def append_history(username: str, command_line: str):
db = GlobalStore.get().require("db")
raw = await db.fetch_val(
"SELECT history FROM users WHERE username = ?",
(username,)
)
history = json.loads(raw or "[]")
history.append(command_line)
history = history[-100:]
async with db.transaction():
await db.execute(
"UPDATE users SET history = ? WHERE username = ?",
(json.dumps(history, ensure_ascii=False), username)
)Рекомендуется ограничивать длину истории.
Используйте стандартный модуль logging.
import logging
logger = logging.getLogger(__name__)logger.info("User %s executed command %s", username, "mycommand")
logger.warning("User %s tried forbidden action", username)
logger.exception("Unexpected error in command")- запуск команды;
- изменение состояния;
- ошибки БД;
- ошибки PTY;
- подозрительные действия.
- приватные SSH-ключи;
- API secrets;
- токены;
- пароли.
- Запустить сервер
- Подключиться по SSH
- Выполнить команду
- Проверить вывод
- Проверить логи
- Повторить
- корректный вывод;
- наличие
\n; - обработка пустых аргументов.
- доступ разрешён / запрещён;
- сценарии “для себя / для других”.
- корректность транзакций;
- rollback при ошибке;
- JSON-поля;
- пустые значения.
- resize окна;
- возврат управления shell;
- cleanup после выхода.
- мышь отключается;
- экран восстанавливается;
- shell не остаётся в “грязном” состоянии.
logger.debug("Args: %r", args)
logger.debug("Permissions: %r", session.extra["permissions"])
logger.debug("Env: %r", session.extra["env"].as_dict())parser = argparse.ArgumentParser()
args = parser.parse_args(...)Это может аварийно завершить команду.
Использовать SafeArgumentParser.
return "Done."return "Done.\n"await db.execute("UPDATE users SET ssh_keys = '[\"key1\"]' WHERE username = ?", (username,))await db.execute(
"UPDATE users SET ssh_keys = ? WHERE username = ?",
(json.dumps(ssh_keys, ensure_ascii=False), username)
)Если команда включает:
- мышь;
- alt screen;
- PTY bridge;
то она должна корректно их выключать.
try:
...
finally:
...logger.info("User key: %s", ssh_key)
logger.info("API secret: %s", api_secret)Проверь:
- возвращает ли
execute(...)значение; - есть ли
\nв конце строки; - не проглатывается ли ошибка в
try/except.
Потому что обычный ArgumentParser вызывает sys.exit().
Используй SafeArgumentParser.
Обычно причина:
- не выключен
alt screen; - не отключена мышь;
- не завершён PTY bridge;
- отправлены escape-последовательности без cleanup.
session.extra["permissions"]session.extra["terminal"]session.extra["env"]Нужно обновить поле saved_env в БД.
Изменение через env.set(...) само по себе не сохраняется между сессиями.
Ниже — пример команды, которая объединяет:
argparse- проверку прав
- интерактивное подтверждение
- работу с БД
- JSON
- логирование
"""
Delete SSH key from user account.
"""
import json
import logging
import argparse
from helpers.globals import GlobalStore
from sshserver.session.manager import get_current_session
logger = logging.getLogger(__name__)
class SafeArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise ValueError(message)
def exit(self, status=0, message=None):
raise ValueError(message or "Argument parsing failed")
def build_parser():
parser = SafeArgumentParser(prog="sshkey-rm")
parser.add_argument("index", type=int, help="Index of SSH key to remove")
parser.add_argument("--user", help="Target username (admin only)")
parser.add_argument("--yes", action="store_true", help="Skip confirmation")
return parser
async def execute(username: str, *args):
session = get_current_session()
terminal = session.extra["terminal"]
permissions = set(session.extra["permissions"])
parser = build_parser()
try:
ns, unknown = parser.parse_known_args(args)
except ValueError as e:
return f"Argument error: {e}\n"
if unknown:
return f"Unknown arguments: {' '.join(unknown)}\n"
target_user = ns.user or username
if target_user != username and "users_manage" not in permissions:
return "Access denied: you can modify only your own SSH keys.\n"
db = GlobalStore.get().require("db")
raw = await db.fetch_val(
"SELECT ssh_keys FROM users WHERE username = ?",
(target_user,)
)
ssh_keys = json.loads(raw or "[]")
if ns.index < 0 or ns.index >= len(ssh_keys):
return f"Invalid key index: {ns.index}\n"
key_preview = ssh_keys[ns.index][:60]
if not ns.yes:
await terminal.output.write(
f"Delete SSH key #{ns.index} for user '{target_user}'?\n"
)
await terminal.output.write(f"{key_preview}...\n")
await terminal.output.write("Confirm [y/N]: ")
answer = (await terminal.input.read_str()).strip().lower()
if answer not in ("y", "yes"):
return "Cancelled.\n"
removed = ssh_keys.pop(ns.index)
try:
async with db.transaction():
await db.execute(
"UPDATE users SET ssh_keys = ? WHERE username = ?",
(json.dumps(ssh_keys, ensure_ascii=False), target_user)
)
except Exception as e:
logger.exception("Failed to remove SSH key for %s", target_user)
return f"Database error: {e}\n"
logger.info(
"User %s removed SSH key #%d from %s",
username, ns.index, target_user
)
return (
f"SSH key #{ns.index} removed from user '{target_user}'.\n"
f"Removed key preview: {removed[:60]}...\n"
)
command = {
"name": "sshkey-rm",
"help": "Remove SSH key by index",
"func": execute,
"permissions": ["sshkey_manage"]
}- Для простых команд возвращайте
str. - Всегда добавляйте
\n, если ожидается отдельная строка вывода. - Для сложного CLI используйте безопасный
argparse. - Для команд с несколькими действиями используйте
subparsers. - Для БД почти всегда используйте
async def. - JSON-поля всегда обрабатывайте через
json.loads()/json.dumps(). - Для связанных изменений используйте
async with db.transaction():. - Права команды задавайте в
command["permissions"]. - Тонкую проверку прав выполняйте через
session.extra["permissions"]. - Интерактивные подтверждения можно делать без PTY.
- PTY-команды всегда синхронизируйте с
terminal.rows/terminal.cols. - После временных режимов терминала всегда делайте cleanup в
finally. - Логируйте действия и ошибки, но не секреты.
- Если команда меняет окружение навсегда — обновляйте
saved_env. - Если команда работает с историей — ограничивайте размер
history.