snens

Привилегии и доверие

Ядро не строит песочницу: владелец даёт агенту власть над машиной. Здесь про то, где эта власть кончается и чем её ограничивать снаружи.

Модель доверия

Агент запускает команды, читает и правит файлы, ходит в сеть — от имени человека, который его поставил. Модель прав одна на всех: подключился — значит управляешь. Гранулярных разрешений внутри харнесса нет намеренно: флаг «этому клиенту можно только читать» ничего не стоит, пока у того же клиента есть run. Отсюда:

Привилегии: брокер snens-priv

Повышать права агенту разрешено только через один исполняемый файл с явным whitelist — не через россыпь строк в sudoers, где матчинг аргументов ошибается незаметно. Установка (делает человек, не агент): положить scripts/snens-priv в /usr/local/bin/snens-priv (root:root 0755), скопировать deploy/snens-priv.conf.example в /etc/snens-priv.conf (root:root 0644) и разрешить ровно один вызов в /etc/sudoers.d/snens-priv:

echo "$(whoami) ALL=(root) NOPASSWD: /usr/local/bin/snens-priv" \
  | sudo tee /etc/sudoers.d/snens-priv && sudo chmod 0440 /etc/sudoers.d/snens-priv

Три ключа конфига; всё, что не описано, запрещено:

units = "caddy my-app"        # юниты systemd (--user), которые можно дёргать
roots = "/var/www /srv/data"  # корни, где разрешены mkdir -p и chown
owner = "deploy:deploy"       # единственный владелец для chown

Пустой конфиг — брокер отказывает во всём: намеренный дефолт, установка никому ничего не выдаёт.

действиечто проверяется
systemctl <глагол> <юнит>глагол из restart/reload/start/stop/status, юнит из units (.service можно не писать), ровно 2 аргумента
mkdir -p <путь>…только -p; каждый путь внутри roots, .. запрещён всегда
chown [-R] <владелец> <путь>владелец ровно из owner, путь внутри roots

Правки /etc, apt-get install, tee в системные файлы в whitelist не входят намеренно — их делает человек. Валидация без выполнения: SNENS_PRIV_DRYRUN=1 snens-priv mkdir -p /var/www/x печатает ok: … либо отказывает кодом 2.

Очередь одобрений

Headless-сессии (веб, десктоп, Telegram) негде ввести пароль, поэтому шим sudo в такой сессии не зовёт askpass, а спрашивает согласие у владельца кнопкой:

  1. шим подключается к сокету <каталог-данных>/approval.sock (0600) и шлёт заявку {session, cwd, argv} — точное действие, которое человек видит в карточке;
  2. ядро эмитит approval.requested в сессию владельца и ждёт;
  3. решение приходит POST /v1/sessions/{id}/approvals/{aid}; allow — вызов уходит как sudo -n через брокер, всё прочее (включая молчание) — отказ.

approval_timeout_seconds (дефолт 300) — сколько ждать; 0 выключает очередь, и шим сразу отдаёт sudo без пароля (тот падает). Заявка одноразовая и привязана к сессии, действию и каталогу. Кнопка не подменяет пароль: одобренный вызов идёт sudo -n и сработает только под NOPASSWD-правилом на брокере; нет правила — «password required», и это ожидаемый отказ, а не обход. В живом TUI (snens tui-local) вместо кнопки askpass.

Токен и пейринг

Файл api.token (0600) ядро создаёт при первом старте; публичный файл или токен короче 32 символов с пробелами — отказ на старте. Передаётся заголовком Authorization: Bearer …; для двух SSE-маршрутов допускается ?token=, потому что браузерный EventSource не умеет заголовков (цена — query в логах прокси), для остального API такого варианта нет. Токен = полный контроль над API и машиной.

Короткий код вместо передачи постоянного токена руками: snens pair печатает 6 цифр. Клиент шлёт POST /v1/pair/claim {code, name} и получает свой постоянный токен; в реестре clients/ лежит только SHA-256: по нему нельзя восстановить токен, и утечка реестра не отдаёт живых секретов. Устройство отзывается поштучно (snens clients revoke <id>, DELETE /v1/clients/{id}).

Защита кода: одноразовый, 5 минут, после 5 неудачных попыток сгорает целиком. Код открывает тот, кто уже внутри (нужен токен), — POST /v1/pair/start.

Транспорт

Ядро слушает loopback. Не-loopback адрес требует явного --allow-remote, публичный — ещё и --insecure-public:

snens serve --listen 0.0.0.0:<порт>                 # отказ: loopback only
snens serve --listen <приватный-адрес>:<порт> --allow-remote

Без --insecure-public проходят только приватные диапазоны: RFC1918, CGNAT (100.64/10 — tailnet), link-local, unique-local IPv6, loopback. Проверка ловит опечатку (0.0.0.0 вместо адреса tailnet), а не заменяет голову. Шифрование — забота транспорта: tailnet, WireGuard, SSH-туннель либо TLS-прокси перед ядром (--tls-cert/--tls-key принимаются, но HTTPS ядро само не отдаёт). CORS открыт для любого origin — это нужно парному клиенту, и единственная реальная защита — токен.

Чего не делать