UNRUIN
документация · v1.0

Как устроено

Устройство

Тонкий слой поверх restic: порты и адаптеры, два класса источников, путь данных.

Unruin — тонкий оркестратор поверх restic. Сам он данные не переносит и не шифрует: это делает restic. Unruin решает, что запускается, где, когда, с какими секретами, и записывает, что получилось.

Слои (предметная область, порты и адаптеры)

            ┌────────────────────────── cmd/unruin ──────────────────────────┐
            │  validate · run · serve · prune                                  │
            └───────────────────────────────┬──────────────────────────────────┘
                                            │ зовёт
            ┌───────────────────────────────▼──────────────────────────────────┐
ПРИЛОЖЕНИЕ  │  internal/app                                                     │
            │  Runner (одно задание)  Daemon (цикл cron)  BuildRepos / Plan     │
            └───────┬───────────────┬───────────────┬───────────────┬───────────┘
                    │               │               │               │
           ┌────────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼─────────┐
АДАПТЕРЫ   │ secrets       │ │ ssh         │ │ restic      │ │ store (SQLite) │
           │ kr/env/file   │ │ построение  │ │ движок      │ │ история        │
           │ resolver      │ │ команд      │ │ + адреса    │ │ состояние cron │
           └────────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬─────────┘
                    │               │               │               │
           ┌────────▼───────────────▼───────────────▼───────────────▼─────────┐
ПРЕДМЕТНАЯ │  internal/domain  (чистая, без сторонних зависимостей)            │
ОБЛАСТЬ    │  SecretRef · Secret · перечисления · Rotation · Run · порты       │
           └──────────────────────────────────────────────────────────────────┘

           internal/config   разбор jobs.yaml → типизированная конфигурация (YAML живёт только здесь)
           internal/source   StreamProducer (ExecProducer) + сборка удалённых команд
           internal/pipeline  источник → копия → параллельная рассылка, свод статуса
           internal/scheduler  чистое решение по cron (on_missed / on_overlap)

Предметная область не импортирует ни библиотеку YAML, ни драйвер базы, ни restic. Каждый внешний инструмент стоит за портом (интерфейсом), который она объявляет, а реализуют его адаптеры. Именно поэтому опасную логику можно покрыть модульными тестами с заглушками.

Два класса источников

restic снимает копию либо со стандартного ввода (поток), либо с пути (папка, которую он читает локально). Это по-настоящему разные пути данных, и они описаны двумя портами:

ПОТОК (postgres / mysql / command)                     ПУТЬ (files / docker_volume)
───────────────────────────────────────────            ─────────────────────────────────────
 цель:  pg_dump / mysqldump / команда                    цель:  restic backup <путь>
            │ stdout по SSH (pipefail)                             │ пишет прямо в хранилище
            ▼                                                      ▼
 оркестратор:  restic backup --stdin ──► основное      (доступ только на дозапись; бинарник
            (байты идут через конвейер, не на диск)      restic доставлен и сверен по сумме)

Поток гонит байты через конвейер оркестратора и никогда не кладёт их на диск. Путь запускает restic на самой цели, поэтому дедупликация по файлам сохраняется, а байты идут напрямую с цели в хранилище. Перед запуском источника-пути Unruin щупает цель на предмет restic и, если его там нет, сам доставляет свой статический бинарник в ~/.cache/unruin/restic по SSH (с проверкой архитектуры; когда restic уже есть, всё это незаметно). Итог: для источников-путей на цель ставить нечего, а источники-потоки restic на цели вообще не трогают. Отказаться — UNRUIN_NO_RESTIC_PUSH=1.

Несколько хранилищ = основная копия + restic copy

restic за один раз пишет в одно хранилище. «Отправить в MinIO и в Seafile и на SFTP» — это не тройник, а одна копия в основное хранилище и затем параллельные restic copy в каждое следующее (параметры нарезки согласованы при создании, поэтому дедупликация сохраняется).

 источник ──► [основное]
                 │  restic copy (параллельно, с ограничением)
        ┌────────┼────────┐
        ▼        ▼        ▼
   [вторая ][третья ][четвёртая]

Статус запуска сводится так: не сделалась основная → failed; у основной код возврата 3 → partial; не дошло обязательное хранилище → failed; не дошло по возможностиpartial; всё чисто → success.

Путь, от которого зависит безопасность

 producer.Start ──► restic backup --stdin ──► ждём источник
                                                   │
            ┌──────────────────────────────────────┤
   источник УПАЛ                            источник в порядке
   (pg_dump умер на середине)                      │
   → запуск НЕУДАЧЕН                       код 0 → всё хорошо
   → забыть обрезанную точку                код 3 → с оговорками
   → НЕ рассылать дальше                            │
                                          → параллельная рассылка копий

Код возврата источника — главный: точка восстановления, снятая с обрезанного дампа, не имеет права засчитаться за успех. Это обеспечивает internal/pipeline, и это доказано интеграционным тестом против настоящего restic.

Секреты

В конфигурации только ссылки (kr://имя#поле, env://ПЕРЕМЕННАЯ, file:///путь), никогда значения. В момент запуска Resolver направляет каждую ссылку по схеме: kr:// → встроенная связка ключей на AES-256-GCM (хранилище по умолчанию, открывается UNRUIN_MASTER_PASSPHRASE; необязательное #поле выбирает одно значение внутри имени), env:// → окружение (там живёт секрет аварийного конверта, который обязан быть вне kr://, чтобы пережить потерю Unruin), file:// → содержимое файла (секреты Docker или k8s). Разрешённое значение — тип Secret, который при печати выводит [REDACTED].

Наименьшие права: по умолчанию один доступ access делает всё. Когда нужно разделение, цели и restic на них получают доступ только на дозапись (access), а уборка идёт отдельным привилегированным доступом (prune_access), который живёт только у оркестратора и никогда не уезжает на цель. Взломанный VPS может дописывать, но не может удалить историю копий.

Расписание и состояние

internal/scheduler.Decide — чистая функция: расписание cron, время прошлого срабатывания, сейчас, политики, идёт ли запуск → выполнять или пропустить и какое время срабатывания сохранить. Daemon тикает раз в интервал и проверяет каждое задание с расписанием. Наложение отсекается и внутри процесса (список идущих, занимается атомарно), и поверх перезапусков и процессов (RunStore.ActiveRun, с проверкой pid владельца — чтобы осиротевшая запись упавшего запуска не блокировала планирование навсегда). История запусков и время последнего срабатывания по заданию лежат в SQLite (store, режим WAL, один писатель).

Тот же цикл ведёт и уборку по расписанию: заданию с prune_schedule (общим или своим) демон применяет правила хранения привилегированным доступом, в очереди с копированием по метке занятости хранилища и со своим временем последнего срабатывания. Так хранилища не растут бесконечно без присмотра. Предел времени запуска (run_timeout, у задания — timeout, по умолчанию щедрый) убивает зависший запуск и записывает его неудачным; итоговая запись в историю идёт на отдельном контексте, поэтому сбой всё равно фиксируется. Живость SSH ловит цель, умершую посреди копирования; по SIGTERM демон сначала перестаёт принимать новые запуски и только потом доводит до конца идущие.

Оповещения

После каждого запуска Runner отправляет domain.Notification, если настроен Notifier и задание подписано на такой исход (notify.on, по умолчанию [failed, partial]). В первой версии есть один канал — вебхук (internal/notify), и его JSON несёт сразу text, content и message, чтобы одно и то же тело подошло ntfy, Slack, Discord и произвольной точке приёма. Доставка по возможности: неудачное оповещение никогда не роняет копирование.

Панель (v1.1)

serve поднимает HTTP-сервер рядом с планировщиком:

  Vue SPA (internal/web, go:embed)         JSON API (internal/api)
  ─────────────────────────────           ───────────────────────
  таблица заданий со статусами   ──GET──▶ /api/jobs   (задания + последний запуск)
  история запусков               ──GET──▶ /api/jobs/{n}/runs
  кнопка «Запустить сейчас»      ─POST──▶ /api/jobs/{n}/run  → Daemon.Trigger
  завести / изменить / удалить  POST/PUT/DELETE▶ /api/jobs[/{n}]
  здоровье и плашка секретов     ──GET──▶ /api/health

Панель ведёт всю конфигурацию — задания, серверы и хранилища — поверх чтения и запуска. Каждое изменение перечитывает jobs.yaml с диска, проверяет результат, прогоняет его туда-обратно (сериализация → разбор, чтобы API не мог записать конфигурацию, которую следующий serve не загрузит), пишет атомарно (временный файл и fsync → переименование → fsync каталога) и подменяет живую конфигурацию. Отвергнутое изменение возвращает ошибки проверки и не трогает работающую конфигурацию. Наружу не выходят только значения секретов: API принимает ссылки (kr:// / env:// / file://), но не разрешённый секрет — хотя поле в панели, работающее только на запись, умеет положить значение прямо в связку ключей. Правка под флагом AllowEdit (включена на loopback или когда задан UNRUIN_API_TOKEN; выключена под UNRUIN_READONLY).

Вход: три режима, и /api/authmode говорит, какой сейчас. none — loopback без настроенного доступа; login — имя и пароль (UNRUIN_AUTH_PASSWORD), сессия в подписанной куке; token — только ключ (UNRUIN_API_TOKEN), сверяемый за постоянное время и уходящий заголовком Bearer из localStorage. serve отказывается стартовать на адресе не-loopback без настроенного доступа. Плашка предупреждает, когда мастер-фразы kr:// нет или она неверная.

SPA вшита в бинарник, и Vue вместе со шрифтами лежит рядом с ней — образ не ходит ни в какую сеть за интерфейсом. Запущенные из панели задания отвязываются от контекста HTTP-запроса и переживают ответ.

Живая проверка (validate --preflight)

Обычный validate смотрит, что конфигурация разбирается и ссылки разрешаются. --preflight добавляет живые проверки через Runner.Preflight: каждый секрет разрешается, до каждого сервера есть SSH (с записью ключа хоста при первом знакомстве), каждое хранилище достижимо (restic cat config). Это превращает «конфигурация разбирается» в «это действительно пойдёт»: опечатка в имени kr:// или лежащий хост падают на коммите, а не в три часа ночи.

Восстановление после аварии

Аварийный конверт — хранилище, чей ключ живёт офлайн (env://), вне kr://. Проверенный лист в RESTORE.md разворачивает копию из него, имея только jobs.yaml, этот офлайновый секрет и restic: ни оркестратора, ни внешнего менеджера секретов для начала восстановления не нужно. Файл хранилища секретов (secrets.kr) и его мастер-фразу тоже держите офлайн — теперь это главный корень всех остальных ключей.

Бесплатно, целиком ваше, наружу ничего не уходит. Сайт статический: ни аналитики, ни куки, ни внешних запросов.