Per-app VPN routing daemon for NixOS
  • Rust 82.8%
  • Python 9.9%
  • Nix 6.2%
  • C 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Pi Agent 36e5c4fef9
All checks were successful
Linux binaries / build (push) Successful in 2m59s
Linux binaries / smoke (push) Successful in 4s
Linux binaries / release (push) Has been skipped
Linux binaries / binary-flake (push) Successful in 41s
fix(shluz): stage binary candidates outside tags until release publish
2026-09-06 14:14:48 +03:00
.forgejo/workflows feat(shluz): publish immutable private binary flakes after CI smoke 2026-09-06 12:49:01 +03:00
ci fix(shluz): stage binary candidates outside tags until release publish 2026-09-06 14:14:48 +03:00
crates fix(shluz-tui): restore single-line application rows 2026-09-06 09:37:16 +03:00
docs/superpowers fix(shluz): handle empty proc namespace links conservatively 2026-09-06 09:27:05 +03:00
examples fix(shluz): resolve private DNS before proxying 2026-09-02 23:06:38 +03:00
modules/shluz fix(shluz): coordinate durable per-app topology and crash recovery 2026-09-06 02:28:09 +03:00
tests/perapp fix(shluz): handle empty proc namespace links conservatively 2026-09-06 09:27:05 +03:00
.envrc chore: capture pre-refactor baseline 2026-07-26 17:14:27 +03:00
.gitignore ci(shluz): build and publish portable Linux archives with Forgejo 2026-09-06 10:53:59 +03:00
Cargo.lock feat(shluz-tui): edit slot Xray overrides 2026-09-02 20:27:07 +03:00
Cargo.toml chore: capture pre-refactor baseline 2026-07-26 17:14:27 +03:00
flake.lock chore: capture pre-refactor baseline 2026-07-26 17:14:27 +03:00
flake.nix test(shluz): add fake per-app boundaries and VM regression foundation 2026-09-06 00:46:24 +03:00
project.md chore: capture pre-refactor baseline 2026-07-26 17:14:27 +03:00
README.md fix(shluz): stage binary candidates outside tags until release publish 2026-09-06 14:14:48 +03:00
shell.nix chore: capture pre-refactor baseline 2026-07-26 17:14:27 +03:00

shluz

shluz — демон маршрутизации трафика отдельных приложений через разные VPN-прокси на NixOS.

Он объединяет подписки, серверы, правила маршрутизации и приложения в слоты. Каждый слот получает свой endpoint, routing profile и способы доступа: автоматическую per-app маршрутизацию, SOCKS5 и/или HTTP proxy.

Warning

Проект находится в ранней стадии разработки (0.1.x). Per-app режим изменяет cgroup v2, nftables и policy routing, поэтому демон запускается с расширенными Linux capabilities.

Возможности

  • маршрутизация приложений по имени процесса через cgroup v2 + nftables + TPROXY;
  • единая генерируемая конфигурация Xray для всех слотов;
  • VLESS endpoints, включая TCP/TLS/Reality и XHTTP-варианты;
  • HTTP-подписки и импорт happ://;
  • routing profiles с правилами по domain, IP, GeoSite и GeoIP;
  • отдельные SOCKS5/HTTP inbounds для слотов;
  • CLI с JSON-выводом и интерактивный TUI;
  • периодическое обновление подписок и проверка доступности серверов;
  • атомарное сохранение состояния;
  • JSON overlays для точечной настройки сгенерированной конфигурации Xray.

Как это работает

application process
        │
        ▼
  cgroup slot-<UUID>
        │
        ▼
nftables + TPROXY ──► Xray inbound ──► routing profile ──► VPN endpoint
  1. В подписку импортируются VPN-серверы и правила маршрутизации.
  2. Слот связывает endpoint, routing profile и доступные inbounds.
  3. Приложение привязывается к слоту по имени процесса.
  4. shluzd отслеживает новые процессы через cn_proc, помещает совпавшие процессы в cgroup слота и перенаправляет их трафик в Xray.
  5. CLI и TUI управляют состоянием демона через Unix socket /run/shluz.sock.

Per-app слой можно отключить и использовать shluz только как менеджер SOCKS5/HTTP-прокси.

Скачать Linux x86_64 без Nix

Откройте публичные Releases shluz-bin и выберите build-<полный source SHA> после успешного job binary-flake. Скачайте оба release assets: shluz-linux-x86_64.tar.gz и SHA256SUMS. Вход, PAT и netrc не нужны. Автоматические архивы исходников — не готовые бинарники. Эти durable assets доступны для trusted main, v* и manual-main без срока Actions retention. Исходные releases v* и временные Actions artifacts остаются в исходном репозитории; наличие Actions artifact до smoke само по себе не доказывает успех — проверяйте job.

$ sha256sum --check --strict SHA256SUMS
$ tar -xzf shluz-linux-x86_64.tar.gz
$ cd shluz-linux-x86_64
$ sha256sum --check --strict SHA256SUMS
$ cat BUILD.json
$ ./shluzd --help
$ ./shluz --help
$ ./shluz-tui --help

Архив содержит три статических ELF (shluzd, shluz, shluz-tui), этот README, BUILD.json (полный commit самостоятельного репозитория, Rust/Cargo, target, SHA256 Cargo.lock, время исходного commit) и контрольные суммы содержимого. Target — x86_64-unknown-linux-musl; Nix и системная glibc для этих ELF не нужны. SHA256 обнаруживает повреждение, не заменяет подпись/доверенный канал: сверяйте commit с доверенным тегом. Релизы не подписываются. Используйте публичный HTTPS с проверкой TLS; не запускайте скачанное через curl | sh. Пакет не устанавливает сервисы, capabilities или firewall rules и ничего не активирует автоматически.

Запуск и внешние зависимости

Xray Core устанавливается отдельно из доверенного источника; для per-app также нужны ip (iproute2), nft (nftables), cgroup v2 и поддержка kernel TPROXY/cn_proc. GeoIP/GeoSite assets при необходимости настраиваются отдельно для Xray. Бинарники не включают Xray или его данные. Значения helper paths по умолчанию остаются NixOS (/run/current-system/sw/bin/...): на другом Linux явно передавайте пути --xray-binary, --ip-binary, --nft-binary, проверив их владельца и права записи.

CLI/TUI подключаются к уже запущенному доверенному демону:

$ ./shluz --socket /путь/к/shluz.sock status
$ ./shluz-tui --socket /путь/к/shluz.sock

Для самостоятельного ознакомления допустим только отдельный непривилегированный proxy-only экземпляр с новым приватным каталогом состояния, без переноса production registry. Пример ниже запускается вручную, без sudo, после отдельной установки Xray и проверки helper paths (замените /usr/bin//usr/sbin на свои):

$ mkdir -m 700 "$HOME/shluz-demo"
$ ./shluzd --no-perapp --socket "$HOME/shluz-demo/shluz.sock" \
    --config "$HOME/shluz-demo/config.json" --xray-config "$HOME/shluz-demo/xray.json" \
    --xray-binary /usr/bin/xray --ip-binary /usr/sbin/ip --nft-binary /usr/sbin/nft

Не запускайте shluzd без --no-perapp ради проверки скачивания: per-app по умолчанию включён и изменяет сетевые ресурсы. Привилегированный production-запуск требует отдельной настройки и проверки; архив не даёт разрешения на rollout. Socket и состояние доступны только доверенным локальным пользователям (см. trust boundary ниже). Вне NixOS готовый systemd unit не устанавливается.

Что проверяет CI и как долго доступны файлы

Workflow запускается только на push main, push тегов v* и workflow_dispatch, не на PR; ручной запуск выполняет jobs только на main. Docker jobs изолированы, без Docker socket, KVM и привилегированных mounts. Hard gates: cargo test --locked --workspace, cargo clippy --locked --workspace --all-targets (существующие warnings допустимы), unit-тесты упаковки/publisher, release build, отсутствие ELF interpreter/NEEDED/RPATH и /nix/store/, SHA256, запуск всех трёх --help из распакованного архива в новом минимальном Debian+Node без Rust/Nix/Xray. Это не тест networking, daemon startup, TUI interaction или egress. Отдельный VM gate с KVM по-прежнему обязателен: nix build .#checks.x86_64-linux.perapp-integration; обычный CI его не выполняет.

cargo fmt --all -- --checkявно advisory, пока сохраняется унаследованный workspace formatting debt: failing step имеет предупреждение и отдельный артефакт formatting-report-…. Зелёный workflow не означает fmt-clean. CI не скрывает новые отклонения: полный fmt diff доступен для review, но baseline новых нарушений автоматически не отделяется от старого долга.

Artifacts (включая fmt report и кандидатов тегов) запрашивают retention 14 дней; серверная политика/удаление запуска может сократить доступность. Скачайте нужные файлы заранее. Release assets не имеют этого CI retention, но администратор может удалить release/репозиторий. В исходном репозитории только явный push нового v* после hard gates создаёт release: draft → два assets → publish. Ручной запуск на теге release не публикует. Отдельный post-build+smoke job автоматически публикует binary flake в публичный forgejo-admin/shluz-bin для push main/v* и manual-main (источник должен принадлежать полной истории main). Повторный запуск при существующем release, даже draft, завершается ошибкой, никогда не удаляет/перезаписывает assets и не двигает тег. Частичный сбой оставляет draft для отдельного ручного разбора; автоматического resume/cleanup нет. Для новой сборки используйте новый тег. Это политика publisher, не серверная WORM-immutability: Forgejo 16 API не запрещает администратору последующие изменения. SHA тега проверяется до draft и перед publish, но API не имеет атомарного compare-and-publish: защита тегов от параллельного перемещения/удаления должна быть настроена владельцем репозитория. Для исходного v* publisher политика в этом исправлении не менялась: существующий Git-тег + draft подвержены преждевременной публикации при синхронизации тегов Forgejo 16. Live acceptance исходного v* release пока не подтверждён.

Исходный release использует краткоживущий автоматический repo-scoped workflow token; checkout — persist-credentials: false. Только последний шаг отдельного binary-flake job получает secret SHLUZ_BIN_TOKEN: PAT со scope write:repository, ограниченный только forgejo-admin/shluz-bin через Forgejo 16 CreateAccessTokenOption.repositories. Он не передаётся build/smoke или third-party Actions, не пишется в remote URL/git config; временный GIT_ASKPASS читает его из env и удаляется. Binary API/Git и новые archive URLs закреплены за https://fjo.osds.digital, независимо от старого internal FORGEJO_API_URL; исходный release API также использует этот HTTPS origin. Проверка TLS включена, redirects запрещены, токен не логируется и не отправляется на legacy HTTP origin. Target должен быть PUBLIC, начальный bootstrap main — только README.md, Actions выключены (нет рекурсивного CI). Repo/token/secret/защиту refs создаёт владелец, не workflow; никакого admin PAT в CI.

Forgejo 16 выдаёт trusted jobs автоматический repository-write token; GitHub-поле permissions не обеспечивает здесь гранулярного read-only/release-only scope и намеренно не указано. Разделение jobs не понижает права автоматического build token. Доверяйте авторам workflow/веток/тегов: изменение самого workflow может запросить секрет. Настройки защиты тегов/доступа остаются обязанностью владельца.

Упаковка воспроизводима для одинаковых бинарников, README и metadata (порядок, uid/gid, mode, tar time и gzip header фиксированы); полная bit-for-bit воспроизводимость компиляции не заявляется: toolchain/actions/container digests закреплены, apt packages не snapshot-pinned. Для локальной проверки из корня standalone репозитория:

$ nix develop --command python3 -m unittest discover -s ci -p 'test_*.py' -v
$ python3 ci/package.py pack target/x86_64-unknown-linux-musl/release dist
$ python3 ci/package.py validate dist "$(git rev-parse HEAD)"

Последние команды требуют уже собранных musl ELF, Rust/Cargo и readelf; dist должен отсутствовать до pack. Локальный Nix dynamic build не является downloadable пакетом и намеренно отвергается валидатором. Workflow использует standalone-root paths: каталог packages/shluz экспортируется в Forgejo через subtree split.

Source flake и binary flake

forgejo-admin/shluz — исходники, Rust build, devShell и VM checks. forgejo-admin/shluz-bin — только проверенные prebuilt x86_64-linux и тот же NixOS-модуль (заменён только package builder). Он экспортирует packages.x86_64-linux.default/shluz, apps всех трёх бинарников и nixosModules.default/shluz. Xray/ip/nft остаются зависимостями модуля.

В binary repo каждый source SHA получает immutable tag/release build-<полный-SHA>; тег указывает на соответствующий binary-flake commit. Git хранит flake, точную копию source flake.lock, модуль и metadata/release.json (source SHA/date/version, archive URL/flat SHA256), не бинарные blobs. Archive/SHA256SUMS — durable release assets. Транзакция: candidate commit в non-tag refs/shluz/staging/<полный-SHA> → draft с будущим build-тегом и target_commitish=candidate → upload → проверка обоих assets через прямые HTTPS /attachments/<проверенный-uuid> → publish создаёт Git-тег → проверка точного tag commit, опубликованного release и анонимного download/hash по обычному planned release URL → fetch нового тега → non-force fast-forward main. До publish build Git-тега нет: иначе Forgejo 16 tag sync может снять draft сам. Staging ref резервируется только create-only CAS (единственный узкий empty-expected --force-with-lease); существующий staging не перезаписывается, refs сохраняются, это не consumer branches/tags. Для main и build-тегов force/lease не используется. До успешной анонимной проверки прежний main не меняется; неожиданно private repo, пропавший/перемещённый тег, изменённый draft или плохой публичный архив блокируют продвижение. Опубликованный идентичный build допускает проверенный no-op/recovery; orphan staging, draft/чужой тег/иной hash требуют ручного разбора, без overwrite/delete/resume. Stale/divergent source не откатывает main, конфликт main оставляет опубликованный pin доступным по тегу.

Для binary flake замените URL в примере ниже на git+https://fjo.osds.digital/forgejo-admin/shluz-bin.git?ref=main (или ?ref=build-<полный-SHA>). Git и release assets доступны анонимно: consumer PAT, netrc и login не нужны. builtins.fetchurl проверяет фиксированный flat hash. CI write secret остаётся приватным и repo-scoped, потребителям он не передаётся. Подробнее — в ci/binary-flake/README.md.

Старые commits/tags/releases/manifests не переписываются и могут содержать legacy HTTP URL. Для миграции обновите consumer lock до нового main или нового build-<полный-SHA>, опубликованного по HTTPS. Старый pin сам не изменит archive URL. Проверенный legacy checkpoint допускается только для локальной сверки истории; новая сборка создаёт новый matching flake commit/build tag/release, затем продвигает main без force. Исторические lock/module/templates сверяются с исходным старым commit.

Новая публикация не обновляет consumer input lock и не активирует хост: требуются явные nix flake update shluz и nixos-rebuild switch. VM/dataplane gate не заменяется бинарным --help smoke.

Установка на NixOS

Добавьте исходный репозиторий как flake input (binary URL описан выше):

{
  inputs.shluz = {
    url = "git+https://fjo.osds.digital/forgejo-admin/shluz.git";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { nixpkgs, shluz, ... }: {
    nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        shluz.nixosModules.default
        ({ ... }: {
          programs.shluz.enable = true;

          # Разрешить пользователю подключаться к Unix socket демона.
          users.users.my-user.extraGroups = [ "shluz" ];
        })
      ];
    };
  };
}

HTTPS URL исходного репозитория не меняет его политику доступа; миграция publisher не меняет visibility/permissions исходников. Публичный binary input описан выше.

Примените конфигурацию и проверьте сервис:

$ sudo nixos-rebuild switch --flake .#my-host
$ systemctl status shluzd
$ shluz status

Опции NixOS-модуля

Опция По умолчанию Назначение
programs.shluz.enable false Включить пакет и shluzd.service
programs.shluz.package пакет из репозитория Используемая сборка shluz
programs.shluz.socketPath /run/shluz.sock Unix socket CLI/TUI
programs.shluz.userGroup shluz Группа с доступом к socket
programs.shluz.xrayPackage pkgs.xray Пакет Xray Core
programs.shluz.enablePerApp true cgroup/nftables per-app слой; добавляет CAP_DAC_OVERRIDE
programs.shluz.cgroupRoot /sys/fs/cgroup/shluz.slice Отдельное дерево приложений, не внутри service
programs.shluz.perAppTableBase 10000 Начало диапазона IPv4 routing tables
programs.shluz.perAppRulePriority 5000 Приоритет IPv4 fwmark rules перед tailnet

Для proxy-only режима:

programs.shluz = {
  enable = true;
  enablePerApp = false;
};

Использование

TUI

$ shluz-tui

Основные клавиши:

  • 1, 2, 3 — Apps, Subscriptions и Slots;
  • Tab / Shift+Tab — переключение вкладок;
  • стрелки или hjkl — навигация;
  • Enter — открыть действие или редактор;
  • q — выход.

Через TUI можно импортировать подписки, создавать слоты, выбирать endpoint и routing profile, привязывать запущенные приложения, настраивать proxy-порты и редактировать Xray overlays.

CLI

# Состояние движка
$ shluz status

# Просмотр сущностей
$ shluz list subscriptions
$ shluz list servers
$ shluz list slots
$ shluz list apps
$ shluz list routing-profiles

# Добавление и обновление подписки
$ shluz subscription add 'https://example.org/subscription' --name primary
$ shluz subscription refresh primary

# Проверка одного или всех серверов
$ shluz ping server-name
$ shluz ping

# Управление Xray
$ shluz engine-reload
$ shluz engine-restart

# Машиночитаемый вывод
$ shluz --json list slots

Путь к socket можно переопределить глобальным параметром:

$ shluz --socket /tmp/shluz.sock status
$ shluz-tui --socket /tmp/shluz.sock

Xray overrides

Слот может содержать JSON overlay, который накладывается на сгенерированную конфигурацию Xray. Обычные JSON-объекты рекурсивно объединяются, а специальные директивы управляют заменой значений и массивами:

{
  "routing": {
    "rules": {
      "#add-before": [
        {
          "type": "field",
          "domain": ["domain:internal.example"],
          "outboundTag": "$proxy"
        }
      ]
    }
  }
}

Поддерживаемые директивы:

  • {"#replace": value} — полностью заменить значение;
  • {"#add-before": [...]} — добавить элементы в начало массива;
  • {"#add-after": [...]} — добавить элементы в конец массива.

$proxy внутри overlay заменяется на outbound выбранного сервером слота. Полный пример для private DNS находится в examples/wpn-private-dns.override.json.

$ shluz slot override-set slot-name examples/wpn-private-dns.override.json
$ shluz slot override-clear slot-name

Некорректный overlay не заменяет рабочую конфигурацию: демон сохраняет предыдущий валидный runtime-вариант и показывает ошибку в TUI.

Промежуточный reliability checkpoint (шаги 14)

Эта версия не готова к live rollout. Стабильный RuntimePlan, durable registry и единый engine/network coordinator, дерево процессов и durable origin/detach реализованы вместе с ACK-проверкой watcher и polling/loss recovery. Отдельный runtime-health RPC/CLI/TUI реализован в шаге6; полная E2E-матрица остаётся шагом7 плана. Наличие configured binding или PID в cgroup не доказывает egress.

Ресурсы назначаются по SlotId/UUID, не по позиции слота. Удалённые allocations остаются tombstones до следующего boot; максимум — 255 boot-local allocations, включая отклонённые candidates. Standalone параметры: --cgroup-root, --perapp-table-base, --perapp-rule-priority. Mark mask — 0xff00, остальные биты сохраняются; tables по умолчанию от 10000, rule priority 5000. Весь диапазон TPROXY-портов 3000030254 включительно зарезервирован только при включённом per-app: proxy-порт в этом диапазоне отклоняется без перенумерации. Проверяется и финальный overlay: только точный planned TPROXY inbound может занять такой порт, включая unused/retiring/tombstoned номера; string ranges тоже проверяются, динамические/непроверяемые port values отклоняются в per-app mode. Proxy-only не создаёт allocations, cgroups или TPROXY inbounds и не резервирует этот диапазон. Пользовательский config.json не переписывается ради fallback.

При per-app startup используется только валидированный last-known-good state с соответствующим планом. Если его нет, invalid overlay оставляет runtime недоступным/pending — политика не удаляется для выдуманного direct fallback. Независимый proxy-only сохраняет прежний override-free startup fallback. Manual reload/restart, startup и mutation retries проходят через coordinator; app-only rebind не перезапускает Xray при неизменном engine config.

Это не атомарная транзакция: prepare не перемещает PID; candidate Xray и union old+candidate interception проверяются до durable commit. Retirement журналируется отдельно после commit. Precommit failure пытается вернуть старый engine/network; неудачный rollback оставляет unavailable и останавливает candidate Xray. Postcommit retirement failure — degraded новая generation с inventory для retry, не «успешный rollback». После readiness/commit выполняется observed membership diff; пока retiring cgroup содержит процессы, interception сохраняется для recovery/retry. Attach/detach errors также не являются rollback.

Registry и boot marker — private файлы рядом с config.json; registry содержит LKG, который может включать приватную конфигурацию: не публикуйте его в диагностике. Intent сохраняется до kernel write, ownership — после подтверждения. Idle same-boot crash восстанавливается; interrupted write или ошибка persistence сохраняет evidence и требует recovery, без угадывания/усыновления/удаления ресурсов по одному tuple. Fresh slot mkdir, route add и nft create не заменяют появившийся чужой ресурс; неоднозначный duplicate rule сохраняет журнал. Это не защита от произвольных параллельных действий другого привилегированного администратора в owned namespace. Не удаляйте registry/boot marker для «починки» и не очищайте table 200. При recovery-required сохраните приватные файлы и read-only inventory для отдельного согласованного recovery. Повреждённый registry в enabled mode блокирует startup; ошибка runtime persistence оставляет диагностический IPC, но замораживает переходы.

Явный disable после crash читает только существующий registry и очищает однозначно owned same-boot interception с persisted namespace и текущими trusted ip/nft paths. Неоднозначный journal/foreign interception не выдаётся за disabled-ready. Старый boot не разрешает удалять совпавший чужой ip tuple; изолированное чужое правило без owned cleanup само по себе не блокирует обычный proxy-only startup.

Перехват ограничен IPv4 TCP/UDP; IPv6 не маркируется. Это не kill switch: старые сокеты не переключаются при attach, watcher не гарантирует первый сокет, connected/unconnected UDP parity ещё не доказана. Явные stop/disable — fail-open после восстановления origins и удаления owned interception. Приложения остаются вне service cgroup демона. Unbind/rebind/stop/disable используют один diff actual membership: (boot_id, TGID, start_time), origin path + inode сохраняются до записи cgroup.procs. Inherited descendants получают origin от проверенного родителя; после reparenting сохраняется исходная binding identity. Ушедший во внешнюю cgroup PID не перетягивается обратно; исчезнувший/recreated origin — recovery-required, без fallback в root и без убийства процесса. После exit запись очищается при сверке.

Общий snapshot/resolver выбирает direct binding, затем ближайшего ancestor, не пересекает UID/session boundary и исключает root/system/infrastructure процессы. Namespace-проверка — host-compatible metadata, не доказательство тождества: production caps не позволяют читать чужие namespace symlinks (ptrace gate). CAP_SYS_PTRACE и privileged helper намеренно не добавлены. Собственный initial-host baseline проверяется по initial PID/user inode и identity uid/gid maps; у кандидата нужны согласованные single-level NSpid/NStgid, matching parsed uid_map и gid_map. Читаемый inode mismatch всегда исключает процесс, EACCES допускает только этот консервативный metadata fallback; missing/malformed данные исключаются с counters и причиной в диагностике. Обычные isolated containers исключаются, но hostile host-root/privileged identity-mapped/shared-PID namespaces не являются обещанной границей защиты. Остаётся финальная numeric-TGID race: у cgroup.procs нет pidfd API. Watcher подписывается на connector (1,1) до initial scan. Успех send не означает подписку: требуется собственный PROC_EVENT_NONE ACK с случайным token ack+1 (kernel send_msg перезаписывает seq). Ошибки ACK/frames, truncation, ENOBUFS, разрыв и переполнение очереди переводят transport в polling; retry — 250ms..30s. События используют TGID, а не TID; event/periodic reconciliation используют один resolver и только ready applied plan. Очередь 256 событий coalesces scans не чаще 250ms; periodic scan каждые 5s закрывает пропуски, в том числе без cn_proc. Снимок ограничен 65536 TGID и проверяемым между процессами бюджетом 2s: частичный снимок никогда не применяется. Это не deadline для блокирующего syscall или journaled migration; на остановке уже начатые операции дренируются, не abort. Transport health (starting/subscribed/polling/stopped), error/counters и pending rescan теперь доступны в shluz status, shluz --json status и TUI. subscribed означает только собственный ACK, не healthy dataplane или успешный attach. Old-socket/first-socket ограничения сохраняются.

Unit использует KillMode=mixed, TimeoutStopSec=60s: TERM получает coordinator, он прекращает приём работы, до 45s дренирует операции и очищает runtime; helpers имеют 15s budget (включая stdin), затем final systemd KILL охватывает service cgroup. Не используются KillMode=none/process. Доступ к socket — только trusted local users.

VM gate: nix build .#checks.x86_64-linux.perapp-integration, production unit, реальный Xray, disposable VMs без host network/cgroup mutation. Step7 fixture проверяет серверный source IP + уникальный payload + nft counters, а не только принадлежность PID: два независимых Xray SOCKS endpoint процесса выходят с разных адресов. Root/helper/grandchild сами создают TCP, connected UDP и sendto sockets; 1200-byte QUIC-like datagram проверяет UDP transport, не QUIC handshake. Проверены existing-tree attach, late/nonleader exec, rebind A→B, unbind, удаление A, stop/restart/crash, IPv6 direct, rollback и durable recovery. Existing TCP stream после rebind остался на A, новый пошёл на B; TCP/UDP sockets, созданные до первого attach, в fixture вышли direct. Это измерения конкретного kernel/Xray, не обещание миграции старых сокетов, отсутствия first-exec race или непрерывности Xray streams.

Coexistence fixture — не actual urix: отдельный реальный TPROXY Xray с mark2, table200 и disjoint sibling sockets, плюс непустая table52. Оба порядка запуска сохранили чужие правила/routes/PID и egress. При одинаковых A/B endpoints/domain rule HTTP Host sniffing дал B в shluz и A в fixture с sniffing disabled. Actual urix и полноценные TLS/QUIC sniffing/handshake parity не прогонялись; это отдельные gates, не основание менять urix. IPv6 egress в fixture остаётся direct до/после rebind/stop; IPv6 firewall/sysctl не изменяются production-кодом.

Fault gates включают invalid overlay, validation/exec-spawn/immediate-exit failure, реальный rejected nft batch (предшествующий flush атомарно откатан), repeated batch, ip add error, registry rename EIO, corrupt/ambiguous/stale-boot registry и forced systemd final group-kill. Весь fault shim/wrappers живёт только в VM. One-shot errors могут автоматически восстановиться до следующего status: failed RPC и monotonic error counter остаются evidence, но старое degraded не должно сохраняться после успешной проверки. Проверка штатного stop подтверждает origin restoration и fail-open новых соединений; forced timeout сохраняет ambiguous journal и отказывает в guessed cleanup. Не удаляйте evidence вручную, не отключайте IPv6 ради видимости защиты. Полный rollout по-прежнему требует independent review и отдельного разрешения.

Per-app runtime health (шаг6)

GetPerAppStatus возвращает отдельный snapshot; PerAppStatusChanged содержит ту же форму. Клиент сначала читает additive capability EngineStatus.perapp_status_supported: старый daemon без этого поля — unknown/unsupported, не disabled/ready. Новый event отправляется только subscribed connections, запросившим GetPerAppStatus; старые RPC variants сохранены. Events обновляются после reconcile/manual actions и каждые 2s; TUI дополнительно запрашивает snapshot и сбрасывает ready при ошибке/timeout. Status-чтение сериализовано с runtime, не изменяет user config или membership. Во время долгой операции возможен unknown по 3s timeout, не устаревший ready.

State Значение
disabled Per-app выключен и требуемая явная cleanup завершена; не ошибка
starting Первичное применение/disable ещё не закончено
ready Есть активный committed plan, engine liveness/readiness проверены, нет известных ошибок membership/watcher и pending rescan
degraded Есть durable generation, но candidate отклонён, runtime/retirement/membership недоступны либо watcher в polling/loss recovery
failed Нет применённого поколения либо startup/disable recovery запрещает работу
unknown Старый daemon, ошибка связи, timeout или недоступен snapshot

applied_generation — последний durable commit, не desired generation и не обещание работающего dataplane: при retirement/engine failure он остаётся видимым. ready отражает последнее успешное применение, текущую engine-проверку и известные ошибки, не независимый непрерывный аудит каждого nft rule/packet. Внешнее изменение kernel resources требует reconcile; факт egress проверяется отдельно тестовым endpoint. assignments — только известные journal (TGID, start_time, slot_id) с независимо перечитанным membership (observed/not_present/unknown), origin-known/relinquished. Это не весь /proc, не process command line и не origin path. Snapshot ограничен 4096 записями и проверяемым между чтениями бюджетом 200ms; assignments_complete=false означает частичный список известных identities, не отсутствие остальных процессов. Блокирующий syscall не имеет жёсткого 200ms deadline. observed_at датирует наблюдение. Счётчики apply/scan errors, watcher losses/queue overflow и namespace exclusions сохраняются в пределах процесса, не обнуляются при успешном retry. Namespace exclusions суммирует наблюдения исключений в завершённых /proc snapshots (повторно исключённый TGID учитывается при каждом scan), не число уникальных процессов; текущие причины хранятся отдельно. Счётчик насыщается, а не переполняется.

JSON CLI additive: прежние top-level processes, last_reconcile, last_error сохранены; добавлены perapp_status_supported и per_app. Скриптам следует игнорировать неизвестные поля. Raw engine error в status заменён фиксированной категорией, поскольку Xray stderr может содержать credentials/override; per-app errors тоже не содержат VPN URI, command output или config. Категории показывают validation, cgroup/ip, nft, engine readiness, membership/retirement, polling и recovery-required. Старые config/override/log APIs остаются привилегированными и могут содержать секреты; не публикуйте их вывод как безопасную диагностику.

TUI пишет configured → slot отдельно от observed journal members [actual slots]. Configured B при observed A после rejection — ожидаемое честное отображение, не успех перепривязки. Pickers add/rebind предлагают только per-app слоты. Membership не доказывает egress соединений; IPv6, старые сокеты и first-exec race не защищены.

При apply_failed исправьте desired override/resource conflict и повторите reload; при membership_failed проверьте права/origin, не переносите PID молча в root. recovery_required/corrupt registry требует сохранения private evidence и отдельного разбора ownership: не удаляйте registry/lock/cgroups/rules для “починки”. Enabled corrupt/config startup оставляет diagnostic IPC без engine/watcher/allocations/cleanup; reload/restart отклоняются, config сохранён. Неизвестный applied generation остаётся null. Live socket/instance lock не заменяется вторым daemon; foreign regular/symlink paths не удаляются. Locks живут до выхода процесса, не unlink при работающем daemon.

Trust boundary: socket mode 0660, programs.shluz.userGroup — trusted local users, не read-only мониторинг и не sandbox для недоверенных пользователей. Члены группы могут менять routing/override и управлять root daemon; conditional CAP_DAC_OVERRIDE усиливает последствия этого доверия. Не добавляйте посторонних пользователей в группу, не открывайте remote API/socket proxy, не передавайте группе write-доступ к runtime registry/lock directories. Эта серия не добавляет capabilities или удалённый API.

Сборка и разработка

Требования: Linux, Rust toolchain, OpenSSL и pkg-config. Для полного per-app режима также нужны cgroup v2, nftables, iproute2 и Xray Core.

$ nix develop
$ cargo build --workspace
$ cargo test --workspace
$ cargo fmt --all -- --check
$ cargo clippy --workspace --all-targets

Сборка пакета через flake:

$ nix build
$ ./result/bin/shluz --help

Структура workspace

Crate Назначение
shluz-core Доменная модель: slots, apps, subscriptions и routing profiles
shluz-protocol JSON-Lines RPC и клиент Unix socket
shluz-engine Абстракция VPN-движка и общие runtime-типы
shluz-xray-engine Генерация конфигурации и управление Xray
shluz-json-overlay Детерминированные рекурсивные JSON overlays
shluzd Демон, persistence, subscriptions и per-app routing
shluz Командный клиент
shluz-tui Интерактивный терминальный интерфейс

Runtime-файлы

Путь Назначение
/run/shluz.sock Unix socket демона
/run/shluz/xray.json Сгенерированная конфигурация Xray
/var/lib/shluz/config.json Пользовательское desired state
/var/lib/shluz/runtime-registry.json Private allocations, applied LKG и ownership journal
/var/lib/shluz/runtime-registry.boot / .lock Boot marker и single-writer lock
/var/lib/shluz/config.instance-lock, /run/shluz.socket-lock Lifetime instance/socket exclusivity (не удалять вручную)
/sys/fs/cgroup/shluz.slice/ cgroups слотов в per-app режиме