Составил CTO-агент 23.09.2026. Все числа — замер этого сервера сегодня, не по памяти. Читать целиком до первой команды: три мины ниже ломают перенос необратимо, и каждая выглядит как «всё прошло нормально».
Сервер держит боевой контур падел-клуба: деньги (счета, касса, накладные), переписку с клиентами и сотрудниками, 28 живых служб, 503 активных крона. Клиенты получают сообщения с этой машины.
196 паролей (базы, API-токены, ключи дверей) лежат в pass и зашифрованы GPG-ключом.
В бэкапе есть gpg-key.asc.enc — но passphrase к нему лежит внутри pass, который
этим же ключом и открывается. Круг замкнут: на чистом сервере распаковать нечем.
Проверено сегодня фактом: связка работает только потому, что ключ уже есть в этом
~/.gnupg.
Что делать: перенести ~/.gnupg (232 КБ) и ~/.password-store (4 МБ) напрямую,
архивом с сохранением прав, первым шагом. Не через восстановление из .enc.
# на старом
tar -czf /tmp/secrets.tgz -C /home/user .gnupg .password-store
# на новом, после копирования
tar -xzf /tmp/secrets.tgz -C /home/user && chmod 700 /home/user/.gnupg
pass show hub/gpg-passphrase >/dev/null && echo "pass работает"
Без этого шага не поднимется ни одна служба: пароль базы у каждой только в pass.
Секреты не выводить в терминал, в чаты и в логи — только файлом между машинами.
Четыре каталога нельзя восстановить из git — там либо нет репозитория, либо служба
работает из ветки, а не из main:
| Каталог | Состояние | Кто от него зависит |
|---|---|---|
projects/CustomerSuccess |
git нет вообще, 100 МБ | служба v7-ai-comm (порт 5181) |
projects/v7-photo-bot |
git нет вообще, 216 КБ | служба v7-photo-bot |
projects/backend-invoices-live |
ветка feat/invoices-api, не main |
v7-invoices-api (5185), v7-guide-api (5186) |
projects/backend-tournament-ops |
ветка feat/tournament-ops, не main |
v7-tournament-ops-api (5187) |
Что делать: копировать projects/ файлами (rsync), а не «клонировать репозитории
заново». Если склонировать из git и переключиться на main — код инструментов, через
которые проводят накладные и турниры, исчезнет с диска.
У нас это уже происходило: git checkout в общем каталоге снёс с диска работающий
invoices-api, потому что его код жил только в ветке. Процесс продолжал работать из
памяти, и любой рестарт убил бы инструмент. Не делать git checkout / switch /
reset --hard в каталогах, на которых сидят службы.
503 активных строки crontab, 38 из них пишут наружу: напоминания о турнирах, поздравления, дайджесты, рассылки, синхронизации в чужие системы.
Если новый сервер запустит кроны, пока старый ещё жив: - клиенты получат по два сообщения (дедуп у сервисов не спасёт — состояние дедупа локальное на каждой машине, файлы состояния разъедутся); - квота MatchPoint API — 500 запросов в сутки на всех — будет съедена вдвое, и синхронизации начнут падать; - два писателя на одну таблицу дают дубли. Свежий пример с этой машины: два загрузчика банковской выписки с разными ключами дедупа дали 92 дубля в проводках.
Что делать: на новом сервере до момента переключения crontab пустой и
systemctl --user службы не включены в автозапуск. Порядок: остановить кроны на старом →
перенести → проверить → включить на новом. Двух активных машин не должно быть ни минуты.
Проверка после каждого шага обязательна: «должно работать» не считается, нужен факт.
Шаг 0. На новом сервере — база. Ubuntu, docker, caddy, pass, gpg, python3,
node, git, postgresql-client.
Два uid — обязательное условие, а не пожелание: пользователь user с тем же uid
(здесь 1000) и служебный caddy с uid 996. Причина в шаге 3: права доступа
раздаются не только владельцем файла, но и именованными ACL (user:caddy:r-x, включая
наследуемые default:), а перенос идёт с --numeric-ids, то есть по числу, а не по
имени. caddy — системный пользователь, его uid назначается по порядку установки
пакетов, и на свежей машине он почти наверняка будет другим. Тогда право на чтение либо
достанется постороннему пользователю с этим номером, либо Caddy молча потеряет доступ к
файлам, которые обязан отдавать. Проверить до переноса данных:
id -u user; id -u caddy # ждём 1000 и 996; не совпало — исправить ДО шага 3
# ACL на целевой ФС должны работать (проверять записью, а не опциями монтирования):
T=$(mktemp); setfacl -m u:nobody:r "$T" && getfacl -p "$T" | grep user:nobody:r; rm -f "$T"
ufw включить здесь же, ДО запуска любых служб. Сейчас наружу открыты только
22, 80, 443; порты бэкендов (5180–5187, 8788 и остальные) закрыты именно файрволом,
а не только тем, что службы слушают 127.0.0.1. Если поднять контейнеры и юниты раньше
файрвола, между их старом и настройкой ufw будет окно, когда порт денежных API торчит
наружу без прикрытия. Проверка: sudo ufw status → active, и в списке ровно 22/80/443.
Шаг 1. Секреты (мина 1). Проверка: pass ls | wc -l = 196.
Шаг 1б. Ключи доступа и мелкие конфиги. ~/.ssh (76 КБ, 14 файлов, 4 пары ключей:
forge-rw, hub-backup-deploy, hub-skills-deploy, do-migration) — без них не
работает ни один git push/pull и останавливается бэкап. Хосты из ~/.ssh/config:
git.forge-stack.io, gitlab-backup, gitlab-skills. Плюс три файла
~/.config/hub-gitlab-namespace, hub-session-accounts, hub-tenant — по ним
определяется, какие репозитории и знания подтягиваются на эту машину.
rsync -aHAX --numeric-ids /home/user/.ssh/ new:/home/user/.ssh/
chmod 700 /home/user/.ssh
chmod 600 /home/user/.ssh/* # всем файлам 600 — публичным ключам и config не вредит
ssh -T git@git.forge-stack.io -p 2222 # проверка: ключ принят
Шаг 2. Включить linger — иначе службы не стартуют до первого входа в систему и поднимутся только когда кто-то залогинится:
sudo loginctl enable-linger user
loginctl show-user user | grep Linger # ждём Linger=yes
Шаг 3. Данные и код (мина 2). Объёмы: hub 13 ГБ, projects 14 ГБ.
rsync -aHAX --numeric-ids \
/home/user/{hub,projects,bin,.claude,.config,.gitconfig,.git-hooks-global}/ …
--numeric-ids и -A важны: часть файлов лежит под чужими uid и с именованными ACL
(см. шаг 0); без них сломаются права дверей и служб.
.gitconfig и .git-hooks-global — не забыть, это предохранитель, а не настройка.
В .gitconfig прописан core.hooksPath=/home/user/.git-hooks-global, и на нём держится
pre-commit проверка на утечку секретов во всех репозиториях. Без этих двух путей на новой
машине секрет можно закоммитить, и ничто не возразит: по виду «всё работает», а гейта
нет. Проверка: git config --global core.hooksPath → /home/user/.git-hooks-global.
Проверки после шага:
git -C /home/user/projects/backend-invoices-live branch --show-current # feat/invoices-api
getfacl -p /home/user/projects/v7padel-site | grep -E '^(default:)?user:caddy'
# ↑ обязано показать user:caddy:r-x И default:user:caddy:r-x — ровно как на старом
Шаг 4. База данных. Контейнер v7-postgres, образ postgres:16-alpine, порт
слушает только 127.0.0.1:5432. Данные — bind-каталог /home/user/v7-postgres/data
(294 МБ, владелец uid 70, права 700).
Надёжнее дампом, чем копированием файлов:
# старый (роли — глобальные, их забывают чаще всего)
docker exec v7-postgres pg_dumpall -U v7admin --roles-only > /tmp/roles.sql
docker exec v7-postgres pg_dump -U v7admin -Fc v7_main > /tmp/v7_main.dump
# новый
docker exec -i v7-postgres psql -U v7admin -d postgres < /tmp/roles.sql
docker exec -i v7-postgres pg_restore -U v7admin -d v7_main --clean --if-exists < /tmp/v7_main.dump
Базы: v7_main 157 МБ (боевая), v7_restore_check 23 МБ, v7_coretest 8 МБ.
20 ролей v7* — у каждой службы своя узкая роль, и права выданы точечно
(до таблицы и до колонки). Без --roles-only службы получат «permission denied»
и встанут молча. Проверка: SELECT count(*) FROM pg_roles WHERE rolname LIKE 'v7%' = 20.
Числа uid 70 / права 700 выше даны для понимания, что именно нельзя ломать, если кто-то
всё же полезет копировать каталог файлами. При переносе дампом этот каталог тащить не
нужно вовсе: новый bind-каталог создаст сам контейнер на новой машине, с правильным
владельцем. Копирование файлами — только при полной остановке обеих баз, и тогда
обязательно sudo rsync -aHAX --numeric-ids.
Шаг 5. Службы и таймеры. ~/.config/systemd/user/: 68 .service + 41
.timer, из них 39 таймеров включено — это вторая, помимо crontab, система
расписаний, и про неё забывают: там сидят выгрузка банковской выписки, обновление
дверей Caddy, сторож бэкапа. Таймеры — тоже «пишут наружу», на них распространяется
запрет из мины 3.
systemctl --user daemon-reload
systemctl --user list-timers --no-legend | wc -l # ждём 39 ПОСЛЕ включения, не до
Включать по одной, снизу вверх по зависимостям; после каждой — curl её порта.
Карта портов (все только на 127.0.0.1):
| Порт | Служба | Что держит |
|---|---|---|
| 5180 | v7-capex-api |
капитальные расходы |
| 5181 | v7-ai-comm |
переписка с клиентами (код без git!) |
| 5182 | v7-admin-cash-api |
касса администраторов |
| 5183 | v7-opex-api |
операционные расходы |
| 5184 | v7-bank-api |
банковские выписки, остаток счёта |
| 5185 | v7-invoices-api |
накладные (из ветки!) |
| 5186 | v7-guide-api |
справочник (из ветки!) |
| 5187 | v7-tournament-ops-api |
турниры (из ветки!) |
| 8788 | v7-people-api |
кабинеты сотрудников |
| 8765, 8791, 8792, 8799 | мосты и вспомогательные | переписка по людям |
Плюс 10 мостов-служб v7-<имя>-bridge (по сотруднику), работающих из
projects/v7padel-backend/people-api.
Шаг 6. Caddy — переносится ОТДЕЛЬНО и под sudo. /etc/caddy/Caddyfile —
2931 строка, 258 блоков handle, права root:caddy 0640. Админ-порт 2019 локальный.
⚠ Шаг 3 его НЕ забирает, и это провалится тихо. rsync из шага 3 идёт от имени
user, а user не входит в группу caddy (проверено) — файл ему недоступен даже
на чтение. rsync домашних каталогов отработает зелёным, Caddy останется со старым
конфигом или без него, и обнаружится это только на проверке ниже, если её не забыть.
Поэтому конфиг везётся явной командой:
# со старого, под sudo — иначе Permission denied
sudo cat /etc/caddy/Caddyfile > /tmp/Caddyfile.copy # затем scp на новый
# на новом
sudo install -o root -g caddy -m 0640 /tmp/Caddyfile.copy /etc/caddy/Caddyfile
sudo rm -f /tmp/Caddyfile.copy # не оставлять копию: см. ниже, в файле живые токены
⚠ Caddyfile — секретный файл, обращаться как с паролями. В нём открытым текстом
лежат живые токены дверей сотрудников (параметры вида t=…, у меня замер даёт
40 вхождений). Он не в pass, но по чувствительности равен ~/.password-store:
не класть в общие каталоги, не коммитить, промежуточную копию удалять сразу.
Здесь легко открыть боевой API наружу. Блоки handle применяются строго в
порядке файла, и правила аутентификации стоят ниже общих путей. Однажды маршрут,
вставленный на четыре строки выше гейта, отдал /api/invoices публично — поймали через
две минуты по логам. При переносе: файл копировать целиком, порядок строк не менять,
блоки не сортировать и не «причёсывать».
Проверка обязательна, и «сайт открылся» ею не считается: запрос к закрытому пути без авторизации должен дать 401/403.
⚠ Проверять ЧЕРЕЗ ПУБЛИЧНЫЙ ХОСТ, не через 127.0.0.1. Запрос на localhost минует
Caddy целиком и тестирует только бэкенд — то есть ровно тот слой, который и так работал
бы. Если проверка защиты идёт по localhost, она покажет зелёное даже при снятой двери.
В Caddyfile 30 хостов-дверей; ручной обход гарантированно пропустит одну — а одна
пропущенная дверь это и есть тот инцидент с маршрутом выше гейта.
sudo caddy validate --config /etc/caddy/Caddyfile
# негативный тест по ВСЕМ дверям одной командой: без токена ждём 401/403
for h in $(sudo grep -oE '^[a-z0-9-]+\.rex\.v7padel\.club' /etc/caddy/Caddyfile | sort -u); do
printf '%-34s %s\n' "$h" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "https://$h/")"
done
# любая 200 без токена — СТОП и разбор, дальше не идти.
# ЕДИНСТВЕННОЕ ЗАКОННОЕ ИСКЛЮЧЕНИЕ: evgeniy-kz.rex.v7padel.space отдаёт 200 публично
# ПО РЕШЕНИЮ ВЛАДЕЛЬЦА от 21.09 — статический хост под внешнюю финмодель, намеренно
# изолирован (без auth-guard, без бэкенда, без пути с нашего сайта); защита — секретность
# адреса, причина и откат записаны в Caddyfile рядом с блоком. Все прочие 200 — дефект.
# Список исключений ровно из одной строки: не «тоже наверное можно».
Готовые инструменты. Baseline старого сервера уже снят 23.09 07:43-07:50 —
файл hub/docs/plans/260923-0745-baseline-pre-migration.md. На новом сервере повторить
те же команды и сверить с этими числами:
python3 ~/bin/v7-doors-diff-audit.py --check
# ожидается «ИТОГ doors-diff: OK — дельты прав нет» при 30 дверях, 8 ключах,
# 263 ACCESS-записях. Хосты и пути от машины не зависят, поэтому ЛЮБАЯ дельта =
# Caddyfile или auth-guard.js доехали не той версией либо не полностью.
NODE_PATH=/home/user/.npm/_npx/e41f203b7505f1fb/node_modules node ~/bin/v7-cabinet-human-smoke.js
# ожидается 10/10 кабинетов (alex, maxim, liza, olya, dariaadmin, kirsanova,
# borodaenko, mihail, selin, malika). Меньше 10 — регрессия, разбирать по конкретному
# кабинету, а не списывать на «не доехало».
Шаг 7. Кроны — последними (мина 3). 503 активных строки; бэкап лежит в
~/.cache/hub-backup/rex/crontab.txt (678 строк с комментариями). Ставить только
после того, как старый сервер их уже не выполняет.
⚠ Переключение домена — не замок. Домены проксируются через Cloudflare, и смена A-записи меняет только то, куда идут ПОСЕТИТЕЛИ. Расписания шлют сообщения людям сами, независимо от DNS: пока на старом сервере живы crontab и таймеры, он продолжает писать клиентам, даже когда домен уже смотрит на новый. Единственный настоящий замок против двойной отправки — снятый crontab и остановленные таймеры на старом сервере, а не переключённый домен. Не путать эти два события.
Перед включением — сверка файлов дедупа. Защита от повторных сообщений держится на
файлах состояния внутри hub/ и people-data/ (истории отправок, журналы дедупа,
«первый раз увиден»). Они переезжают вместе с hub, но это надо подтвердить, а не
предположить — побайтовой сверкой до и после переноса:
# на старом (после остановки расписаний) и на новом — суммы обязаны совпасть
find ~/hub/people-data ~/hub/data -name '*dedup*' -o -name '*first-seen*' | head -5 | xargs md5sum
Расхождение здесь означает, что кто-то получит поздравление или напоминание второй раз.
Шаг 8. Хуки рабочих сессий. В ~/.claude/settings.json живут предохранители:
PreToolUse (гейт деплоя и запрет checkout в каталогах живых служб),
UserPromptSubmit (стоп-латч владельца), PreCompact. Они переезжают вместе с
.claude, но проверить, что файл на месте, стоит: без них снимаются защиты, которые
уже спасали этот контур.
Добавлено после самопроверки плана — без этого перенос теряет данные тихо.
Пока копируются 27 ГБ (hub 26 711 файлов, projects 175 429), старый сервер продолжает
работать и ПИСАТЬ: входящие сообщения клиентов ложатся в катушку
hub/people-data/_shared/wa-webhook-spool.jsonl (1005 записей, порядка 15 в сутки), пишутся
кассовые записи, леджеры отправок, журналы дедупа. Всё, что придёт ПОСЛЕ копирования и ДО
переключения, пропадёт без следа — и пропадёт именно то, что дороже всего: сообщения людей
и деньги.
Поэтому копирование делится на два захода:
Порядок окна: предварительный rsync → остановить расписания и службы на старом → дельта → дамп базы (тоже после остановки, иначе снимете состояние «вчера») → проверки → включение нового → DNS.
rsync на 27 ГБ может оборваться и вернуть ненулевой код, который легко просмотреть в потоке.
# повторить ту же команду с --dry-run: должно быть 0 файлов к передаче
rsync -aHAX --numeric-ids --dry-run --stats /home/user/hub/ new:/home/user/hub/ | tail -20
# и грубая сверка счётом файлов на обеих машинах:
find /home/user/hub -type f | wc -l # старый даёт 26711 на 23.09
find /home/user/projects -type f | wc -l # старый даёт 175429
~/.claude — 1,7 ГБ, из них основное это транскрипты рабочих сессий. Обязательны только
settings.json (предохранители) и projects/*/memory/; остальное можно довезти потом и не
держать на нём окно простоя.
У переноса обязан быть путь назад, иначе первая же несошедшаяся проверка ставит выбор между «жить с поломкой» и «восстанавливать наобум».
git checkout / switch / reset --hard в projects/*, где сидят
службы: четыре каталога потеряют код (мина 2).gpg-key.asc.enc как основной путь (мина 1).--numeric-ids/sudo — uid 70 и
права 700 обязательны, иначе Postgres не стартует.pass show <путь> на машине, без копирования значения куда-либо.Caddyfile обычным конфигом — в нём живые токены дверей: копии из
/tmp удалять сразу, в репозитории не класть.127.0.0.1 — это тестирует бэкенд, а не дверь, и даёт
зелёное даже при снятой защите. Только через публичный хост.chmod -R): часть файлов намеренно 0640 и
0440, на этом держится разграничение доступа между людьми.Исполнитель работает с машиной целиком, то есть в процессе имеет доступ к материалу
секретов: ~/.password-store (196 записей), ~/.gnupg и Caddyfile с живыми токенами
дверей. Это нормально для работы, но из этого следуют три обязательства.
Что делает исполнитель:
1. Не оставлять копий: промежуточные архивы (/tmp/secrets.tgz, /tmp/Caddyfile.copy,
дампы базы в /tmp) удалить сразу после распаковки — shred -u или rm.
2. Не выводить значения секретов в терминал, историю команд, логи, скриншоты и переписку.
Проверять наличие — pass show <путь> >/dev/null && echo ok, без печати значения.
3. По окончании передать нам список того, к чему был доступ (какие файлы секретов
открывались/копировались, оставались ли копии где-либо ещё). Одной строкой, честно.
Что делаем мы после его работы (не задача исполнителя, ему это знать только для контекста): ротация паролей и токенов, к материалу которых была доступна третья сторона — пароли 20 ролей базы, 40 токенов дверей в Caddyfile, внешние API-токены. Ротация идёт после успешного переезда и приёмки, по одному сервису с проверкой, чтобы не смешивать две причины поломки.
Не «сервисы запустились», а проверка тем способом, каким это видит потребитель:
pass ls | wc -l → 196.loginctl show-user user | grep Linger → Linger=yes.systemctl --user list-units --type=service --state=running | wc -l → 28.SELECT count(*) FROM pg_roles WHERE rolname LIKE 'v7%' → 20; размер v7_main ≈ 157 МБ.backend-invoices-live = feat/invoices-api,
backend-tournament-ops = feat/tournament-ops.projects/CustomerSuccess (100 МБ), projects/v7-photo-bot.git config --global core.hooksPath → /home/user/.git-hooks-global (гейт секретов жив).getfacl на projects/v7padel-site показывает user:caddy:r-x и default:user:caddy:r-x.sudo ufw status → active, наружу только 22/80/443.v7-doors-diff-audit.py --check → «дельты прав нет» (30 дверей, 8 ключей,
263 ACCESS); v7-cabinet-human-smoke → 10/10 кабинетов. Baseline старого
сервера: hub/docs/plans/260923-0745-baseline-pre-migration.md.crontab -l | grep -vc '^#' → 503 (и только после остановки старого сервера).Старый сервер: кроны сняты, службы остановлены — подтвердить до включения нового.
Входящий путь жив: катушка hub/people-data/_shared/wa-webhook-spool.jsonl на новом
сервере содержит не меньше записей, чем на старом в момент остановки (на 23.09 — 1005), и
после включения прирастает на новых сообщениях. Записей меньше — дельта не доехала,
сообщения людей потеряны.
rsync --dry-run по каждому перенесённому каталогу показывает ноль
файлов к передаче.Если какой-то пункт не сходится — сказать об этом прямо, а не «вероятно, подтянется». Тихий недоезд здесь дороже задержки: половина этого контура — деньги и переписка с людьми, и расхождение всплывёт через дни, чужими цифрами.
Внутренний документ V7. Адрес не индексируется и не публикуется — не пересылайте его дальше исполнителя. Страница снимается после приёмки переноса.