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

Настройка

Справочник jobs.yaml

Каждое поле файла конфигурации: что принимает и что делает, если его не написать.

Здесь описано текущее, упрощённое устройство. Что убрала июльская чистка 2026 года (Vaultwarden/vw://, rclone mode: plain, webdav; добавлена доставка restic) и как переписать старую конфигурацию — в SIMPLIFICATION.md.

Unruin настраивается одним файлом YAML (по традиции jobs.yaml), который держат в git. Неизвестные ключи отвергаются при загрузке, поэтому опечатка падает громко. unruin validate <файл> проверяет всё: ссылки, параметры источников, расписания, правила хранения и наличие доступов. Добавьте --preflight, чтобы прогнать и живые проверки — разрешить каждый секрет, соединиться по SSH с каждым сервером и убедиться, что каждое хранилище достижимо. Тогда опечатка в имени kr:// или лежащий хост упадут сейчас, а не в три часа ночи.

Секреты в файл не пишут никогда. В нём ссылки, разрешаемые в момент запуска:

  • kr://<имя>[#<поле>] — собственное шифрованное хранилище Unruin (по умолчанию). Ниже подробнее.
  • env://<ПЕРЕМЕННАЯ> — переменная окружения (там живёт офлайновый секрет аварийного конверта).
  • file:///путь/к/секрету — содержимое файла (секрет Docker или k8s, примонтированный файлом, либо ключ SSH для опытов).

Встроенное хранилище секретов (kr://) — способ по умолчанию

У Unruin есть своё хранилище на AES-256-GCM в каталоге состояния (secrets.kr), которое открывает UNRUIN_MASTER_PASSPHRASE (PBKDF2-SHA256). В jobs.yaml лежит только ссылка kr://имя — зашифрованные значения живут в состоянии, а не в git. Вести его можно из командной строки (значения читаются со стандартного ввода, а не из аргументов) или вставлять в поле панели, работающее только на запись:

sh
export UNRUIN_MASTER_PASSPHRASE='длинная фраза'
printf %s 'ключ-хранилища'  | unruin secret set restic/minio   # пароль, без перевода строки
unruin secret set ssh/vps-prod-1 < ~/.ssh/id_ed25519            # ключ, дословно, с переводом строки
unruin secret ls                                                # только имена, никогда значения
unruin secret rm ssh/old-host

Дальше ссылаетесь: ssh_key: "kr://ssh/vps-prod-1", repo_password: "kr://restic/minio".

Имя kr:// может нести необязательное #поле, чтобы один логический доступ держал несколько значений — например, два ключа хранилища S3 под одним именем:

sh
printf %s 'AKIA…'    | unruin secret set minio/primary#access_key
printf %s 'wJalr…'   | unruin secret set minio/primary#secret_key

и ссылки kr://minio/primary#access_key и kr://minio/primary#secret_key.

Фраза в окружении открывает хранилище при каждом перезапуске сама — ни сессии, которую надо обновлять, ни внешнего процесса. Размен: кто может прочитать окружение, тот может расшифровать хранилище. Плашка ⚠ в панели загорается, если фразы нет или она неверная. Секрет аварийного конверта держите на env://, а не здесь, чтобы он пережил потерю и Unruin, и этого хранилища.

Убрано: vw:// (Vaultwarden через bw) вырезан целиком во время чистки — ни BW_SESSION, ни Node/Bitwarden CLI в образе. Конфигурация, где остался vw://, не загрузится, а сообщение приведёт в SIMPLIFICATION.md; замените каждый vw://папка/запись#поле на kr://папка/запись#поле — грамматика та же.

Верхний уровень

yaml
servers:        { <имя>: <сервер> }
rotation:       <хранение>          # общее значение; задания накладываются поверх
destinations:   { <имя>: <хранилище> }
jobs:           [ <задание>, ... ]

servers

yaml
host_key_policy: tofu             # общее: tofu (доверие при первой встрече) | strict

servers:
  vps-prod-1:
    host: 10.0.0.11                 # обязательно
    port: 22                        # по умолчанию 22
    user: root                      # обязательно
    ssh_key: "kr://ssh/vps-prod-1"   # обязательно (ссылка на закрытый ключ)
    host_key: ""                    # можно закрепить ключ хоста; иначе он запомнится при первом соединении
    host_key_policy: strict         # можно переопределить общее правило для сервера

Правило про ключ хоста

host_key_policy управляет StrictHostKeyChecking в SSH:

  • tofu (по умолчанию) — принять новый: первое соединение доверяет ключу хоста и записывает его в known_hosts, все дальнейшие сверяются с ним.
  • strict — ключ хоста обязан уже быть в known_hosts; незнакомый хост — жёсткий отказ. Сначала засейте unruin validate --preflight <cfg> (он соединяется в режиме доверия и записывает ключ), сверьте отпечатки по другому каналу, потом переключайтесь на strict.

Задаётся глобально и переопределяется по серверу. known_hosts лежит в каталоге состояния (см. окружение), поэтому закреплённые ключи переживают перезагрузку. И каталог состояния по умолчанию находится в пользовательском конфиге, а не в /tmp: чистка временных файлов не должна тихо заново доверить всему парку.

rotation (сколько хранить)

Ложится на restic forget --prune. Задаётся глобально и/или в задании; правила задания накладываются на общие — каждый заданный ключ побеждает, остальные наследуются.

yaml
rotation:
  keep_last:    3
  keep_hourly:  24
  keep_daily:   7
  keep_weekly:  4
  keep_monthly: 12
  keep_yearly:  3
  keep_within:  "14d"               # в формате длительности restic

Пустые правила хранения значат «хранить вечно»: validate предупреждает, а prune отказывается работать — удалять всё он не станет.

prune_schedule (уборка сама)

По умолчанию правила хранения применяются, только когда вы зовёте unruin prune. Задайте prune_schedule (выражение cron), и serve будет применять их сам, привилегированным доступом, — чтобы без присмотра хранилища не росли бесконечно:

yaml
prune_schedule: "30 4 * * *"          # общее: каждый день в 04:30
jobs:
  - name: app-db-nightly
    # ...
    prune_schedule: "0 5 * * 0"       # у задания своё: раз в неделю

Задание без правил хранения убирать нельзя (validate отвергает prune_schedule без rotation). Уборка по расписанию встаёт в очередь с копированием в то же хранилище по метке занятости и никогда не снимает метку уборки сама.

run_timeout (защита от зависания)

Запуск, перешагнувший свой предел по времени, обрывается и записывается неудачным: зависшая цель (замерший pg_dump, мёртвое, но открытое соединение SSH) не должна держать задание вечно.

yaml
run_timeout: "3h"                     # общее
jobs:
  - name: big-files
    # ...
    timeout: "8h"                     # у задания своё; "0" выключает

Не задали — применяется щедрое встроенное значение, шесть часов. Вместе с живостью SSH это и есть подпорка надёжности для работы без присмотра.

notifications

Куда приходят исходы запусков. В первой версии один канал — вебхук, который работает с ntfy, Slack, Discord и произвольной точкой приёма: в теле есть поле сообщения, которое ждёт каждый из них (text, content, message), плюс структурные job, status, detail, time.

yaml
notifications:
  webhook:
    url: "kr://notify/webhook"      # ссылка на секрет — в адресе часто есть ключ

Какие исходы отправлять, решает notify.on у задания (по умолчанию [failed, partial]). Если канал не настроен вовсе, validate предупреждает, что исходы никуда не придут.

destinations

Каждое хранилище — хранилище restic. Чтобы копия уходила больше чем в одно место, перечислите у задания несколько (ниже); остальные наполняются параллельными restic copy, и дедупликация сохраняется.

yaml
destinations:
  minio-primary:
    mode: restic                    # можно не писать: restic — единственный режим и значение по умолчанию
    backend:                        # типы бэкендов ниже
      type: s3
      endpoint: https://minio.example.com
      bucket: backups
    access:       "kr://minio/primary"        # доступ к бэкенду
    prune_access: "kr://minio/prune"           # необязательный привилегированный доступ (только уборка, только у оркестратора)
    repo_password: "kr://restic/minio-primary" # ключ шифрования хранилища
  • Каждое хранилище — хранилище restic. Первое хранилище в задании основное (источник копируется в него), остальные наполняются restic copy.
  • mode по умолчанию restic, его можно не писать. Другого режима нет; оставшийся mode: plain unruin validate отвергает.
  • access не нужен бэкендам, которым не нужны доступы (например, local).
  • prune_access необязателен: один access делает всё, включая уборку. Задавайте его только ради разделения — когда у целей доступ только на дозапись, а forget/prune ходят отдельным привилегированным ключом, который живёт только у оркестратора и на цель не уезжает. Не задан — уборка идёт через access.
  • Для S3 направьте accessprune_access) на один доступ с двумя полями — access_key и secret_key, — например kr://minio/primary#access_key и kr://minio/primary#secret_key под одним именем kr://minio/primary.

Нужна вторая копия на другой площадке? Заведите второе хранилище restic и укажите его в задании после основного — Unruin разошлёт туда параллельным restic copy.

Типы бэкендов

type поля адрес в restic
local path <path>
s3 endpoint, bucket s3:<endpoint>/<bucket>
sftp host, path, user?, port? sftp:[user@]<host>:<path>
rest url rest:<url>

У хранилища sftp есть ещё необязательная ссылка ssh_key: — рядом с repo_password, а не внутри backend. Бэкенд sftp в restic запускает свой собственный ssh; ssh_key даёт ему ключ и закрепляет ключ хоста (доверие при первой встрече, запись в каталоге состояния), чтобы он авторизовался и проверил хост, а не падал с «Host key verification failed». Обычно это тот же ключ, который вы дали серверу; в панели при выборе уже заведённой машины для хранилища «Другой компьютер» её ключ подставляется сам. Без него restic сваливается на окружающий ssh контейнера — без ключа и без known_hosts, и это самая частая причина сбоев хранилищ по sftp.

jobs

yaml
jobs:
  - name: app-db-nightly            # обязательно, уникально
    server: vps-prod-1              # должно ссылаться на сервер
    source: { type: postgres, db: app }
    dest:                           # [0] — основная копия; остальные — restic copy
      - { name: minio-primary, required: true }
      - { name: offsite-sftp,  required: false }   # по возможности
    schedule: "0 3 * * *"           # cron из пяти полей; пусто — только вручную
    on_missed: skip                 # skip (по умолчанию) | run     (догонять после простоя?)
    on_overlap: skip                # skip (по умолчанию) | queue   (предыдущий запуск ещё идёт?)
    rotation: { keep_daily: 14 }    # накладывается на общее по ключам
    verify: true                    # по умолчанию true: контрольное восстановление после копии
    notify: { on: [failed, partial] }   # по умолчанию [failed, partial]
    prune_schedule: "0 5 * * 0"     # можно переопределить общее расписание уборки
    timeout: "8h"                   # можно переопределить общий run_timeout ("0" выключает)
    repo_password: "env://BREAKGLASS_RESTIC_PW"   # можно переопределить у задания (аварийный конверт)

Типы источников

type класс обязательные параметры что делает
postgres поток db pg_dump <db> (согласованный за счёт MVCC)
mysql поток db mysqldump --single-transaction <db> (обязательно)
command поток command любая команда, пишущая в stdout
files путь path restic на цели по этой папке
docker_volume путь volume снять именованный docker-том (через вспомогательный контейнер)

Источники-потоки льются по SSH в restic backup --stdin на оркестраторе. Источники-пути запускают restic на цели. Дампы не сжимаются заранее: restic сам нарежет, дедуплицирует и сожмёт.

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

Окружение

переменная зачем
UNRUIN_MASTER_PASSPHRASE открывает встроенное шифрованное хранилище (kr://)
UNRUIN_STATE_DIR история запусков в SQLite, ключи SSH, known_hosts и secrets.kr (по умолчанию $XDG_CONFIG_HOME/unruin/state, постоянный каталог — не /tmp)
UNRUIN_RESTIC_BIN какой restic запускает оркестратор (по умолчанию restic из PATH)
UNRUIN_NO_RESTIC_PUSH 1 — не доставлять restic на цели источников-путей
UNRUIN_RESTIC_PUSH_BIN какой бинарник restic отправлять на цели вместо собственного
UNRUIN_TICK шаг планировщика у serve (по умолчанию 1m)
UNRUIN_LISTEN адрес панели и API (по умолчанию 127.0.0.1:8080; на адресе не-loopback без ключа serve стартовать откажется)
UNRUIN_API_TOKEN если задан, требуется как Authorization: Bearer <token>; нужен, чтобы выставить панель за пределы loopback
UNRUIN_READONLY запрещает любые правки конфигурации (создание, изменение, удаление заданий) независимо от адреса
UNRUIN_INSECURE осознанный отказ от проверки: адрес не-loopback без ключа
то, на что ссылается env:// любые офлайновые секреты (например, BREAKGLASS_RESTIC_PW)

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