VFLIN
ВойтиСкачать

docs · docs/50-cloud/DEPLOY.md · перевод от 2026-09-12 · с английской ревизии 7e46c84bea60

Свой сервер

compose-сборка, секреты, TLS, резервные копии, обновление

Как поднять облако на арендованной машине примерно за час и чего это стоит. Написано S1 2026-09-04 по прогону против compose-сборки на машине оператора. Каждое утверждение несёт одну из трёх пометок — та же дисциплина, что у рыночной полосы для продуктовых утверждений:

  • [MEASURED] — сделано в этой сессии, и число лежит в worklog/server.md (internal: docs/90-meta/worklog/server.md).
  • [BUILT] — путь кода существует и покрыт тестами, но именно эта конфигурация здесь не запускалась.
  • [INTENT] — продумано, не запускалось. VPS для этой сессии не арендовали (покупает владелец, а не полоса).

Сборка — это cloud/docker-compose.yml (internal: cloud/docker-compose.yml): Postgres + Redis + MinIO и сама служба, собранная из cloud/Dockerfile (internal: cloud/Dockerfile). Один файл служит и разработке, и CI, и VPS.


0. Что сервер видит и чего не видит — прочитайте, прежде чем что-либо арендовать

Обещание продукта — хранение с нулевым разглашением (ADR-0014, ADR-0019): сервер хранит то, что ему нужно для координации, и не может прочитать профиль. Это было перемерено, а не перечитано, 2026-09-04 — запросами к базе данных и листингом bucket после полного круга между двумя устройствами [MEASURED]:

где в открытом виде запечатано / захэшировано (сервер не может прочитать)
accounts адрес почты · флаг totp_enabled · дата создания (S10: для неподтверждённого адреса — время его ПОСЛЕДНЕЙ регистрации: замещающая регистрация перезапускает 24-часовой отсчёт) · 16-байтная соль входа (не секретна по замыслу) · verified_at (S8: когда адрес подтверждён кодом из письма; NULL = зарегистрирован, но не подтверждён — войти нельзя, удаляется через 24 h после последней регистрации) · email_code_failures (бюджет неверных кодов, 0–5) · auth_kdf (S10, ADR-0040: версия вывода сохранённого верификатора — NULL = прежний, auth-v1; метка, записанная вместе с верификатором и объявляемая на login-init, которая говорит клиенту, как выводить, и никогда не позволяет выводить что-либо серверу) password_hash — хэш Argon2id верификатора, выведенного на клиенте, а не парольной фразы (ADR-0019 C1) · totp_seed_enc 60 байт, AEAD под VFLIN_TOTP_KEY
plans (S12) прайс владельца как ДАННЫЕ: код, цена за месяц, потолки (NULL — потолка нет), продаётся ли тариф ещё. Это не про какой-то аккаунт; строка здесь потому, что правка цены не должна быть релизом
email_codes (S8) relay_response + relayed_at (S12, ISSUE-204: что ответил почтовый релей, когда взял письмо, — строка состояния и, у большинства релеев, id очереди. Это про КОНВЕРТ: кода там нет никогда, и именно это владелец предъявляет провайдеру, когда письмо потерялось) · для какого аккаунта код, его назначение (verify — активирует аккаунт; device — допускает одно новое устройство и привязан к хэшу этого устройства), срок действия (15 min), использован ли он — и HMAC под секретом сервера от 6-значного кода (code_hash), а не сам код; строка с NULL вместо аккаунта — это приманка, которую при resend-code оставляет неизвестный или уже подтверждённый адрес, чтобы обе ветви делали одинаковую работу сам код (отправляется один раз, одна попытка, 15 min)
devices id устройства (UUID демона) · имя устройства · когда было в сети последний раз refresh_token_hash (43 символа; сам токен никогда не хранится) · secret_hash (S9, 43 символа: SHA-256 секрета устройства, который последний вход выдал этому устройству, — то, что делает его «знакомым»; заменяется при каждом входе, исчезает с device.revoke, NULL, пока устройство снова не войдёт с кодом из письма; сам секрет никогда не хранится)
profiles_meta id профиля · ключ блоба · хэш содержимого · размер шифртекста · wallets (S12: сколько кошельков держит профиль — число, которое ЗАЯВЛЯЕТ клиент при push/commit, а не считает сервер; именно против него проверяется потолок кошельков в тарифе, то есть потолок ограничивает честного клиента, а не изменённого, и контракт это говорит) · вектор версий (id устройств → счётчики) · fence (S5: fence синхронизационной аренды последнего принятого push, монотонный счётчик — метаданные координации, не выведенные ни из какого открытого текста) · состояние · дата обновления
account_keyrings JSON-конверт: version, параметры KDF (mCost/tCost/pCost), saltKek, реестр ключей устройств (id устройства → 32-байтный открытый ключ подписи), accountPub wrappedMkPassword, wrappedMkRecovery, wrappedAccountPriv (nonce AEAD + шифртекст) · MK-MAC
bucket vflin-blobs для каждого блоба — открытый манифест (VFB1 + JSON): id профиля · id устройства · вектор версий · coreVersion · payloadSize открытого текста · внутренний хэш содержимого — описан в PROFILE_STORAGE_SYNC.md §«манифест (открытый, с защитой целостности)» wrappedDek · sealedRecord (имя / отпечаток / прокси) · полезная нагрузка — AEAD, непрозрачные байты
audit_log, orgs, shares, subscriptions пусты после этого круга: демон никогда не отправляет записи журнала действий (log.append для него недостижим), команда не создавалась, тариф не покупался
password_resets (S4) для какого аккаунта сброс, его срок действия, использован ли он — и хэш отправленного токена (token_hash), а не сам токен; строка с NULL вместо аккаунта — приманка, которую оставляет неизвестный адрес, чтобы reset-init делал одинаковую работу в обеих ветвях сам токен сброса (отправляется один раз, 30 min, одноразовый)
billing_events (S4) для какого аккаунта событие, его тариф/статус, номер заказа у провайдера, когда оно пришло — чтение истории платежей для счетов в кабинете

Итак, оператор с root-доступом к VPS узнаёт: кто завёл аккаунт, сколько у него устройств и профилей, какого размера каждый профиль, какая версия Chromium открывала его последней и когда он менялся. Он не узнаёт ни парольной фразы, ни содержимого профиля, ни cookies, ни отпечатка, ни прокси. Говорите пользователям именно это; не говорите «сервер ничего не хранит».


1. Что арендовать

нужно значение почему
ОС Ubuntu 24.04 LTS собственный apt-репозиторий Docker целится в неё; шаги ниже предполагают apt
ОЗУ 2 GB сборка в простое занимает ≈ 165 MiB [MEASURED]: служба 8.7 MiB, Postgres 60 MiB, Redis 6 MiB, MinIO 89 MiB. 1 GB тоже потянул бы; 2 GB оставляют место на сборку образа (Go компилируется внутри Docker)
CPU 1 vCPU служба упирается в ввод-вывод; Argon2 считается на клиенте (верификатор выводится на устройстве)
Диск 20 GB+ образ 35 MB, базовые образы ≈ 900 MB, дальше — блобы профилей, см. §7 про рост
Сеть публичный IPv4, открытые порты 80/443 Caddy нужен 80 для проверки ACME и 443 для TLS
DNS две A-записи: api.<domain> и s3.<domain> демон загружает блобы прямо в объектное хранилище по presigned-ссылкам, поэтому хранилищу нужно своё публичное имя (§4)

Ориентир по стоимости (цены по прейскуранту, прочитанные 2026-09-04 на двух сравнительных страницах; это не рекомендация — провайдера выбирает владелец, он же решает про оплату криптой):

провайдер тариф на 2 GB крипта
Hetzner Cloud ≈ $3.60 / мес (1 vCPU, 25 GB NVMe) на прочитанной странице не указано
Cockbox ≈ $3 / мес Bitcoin, Monero
UltaHost ≈ $5.50 / мес Bitcoin, USDT, ETH
Vultr / DigitalOcean ≈ $6 / мес (1 vCPU, 50–55 GB) на прочитанной странице не указано
Shinjiru ≈ $7 / мес Bitcoin
Njalla ≈ $15 / мес Bitcoin, Monero

Закладывайте $4–7 в месяц на машину плюс домен. Других регулярных затрат в этом устройстве нет: ни управляемой базы данных, ни CDN, ни счёта за объектное хранилище (MinIO на том же диске). Cloudflare R2 вместо MinIO поддержан через переменные окружения (VFLIN_S3_ENDPOINT + VFLIN_S3_USE_SSL=1 + VFLIN_S3_REGION=auto) [BUILT], но в этой сессии не запускался.


2. Базовая система (один раз) — [INTENT]

# as root on the fresh VPS
apt-get update && apt-get -y upgrade
curl -fsSL https://get.docker.com | sh          # Docker Engine + the compose plugin
apt-get -y install caddy git ufw
ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable
adduser --disabled-password --gecos "" vflin && usermod -aG docker vflin

Больше ставить нечего: Go компилируется внутри сборки образа, так что на VPS никогда не появляется тулчейн Go. docker compose version должен печатать v2.x (скрипт get.docker.com его ставит).


3. Взять код и породить секреты — [MEASURED locally]

На VPS нужен только cloud/ — это контекст сборки Docker. Склонируйте репозиторий (ключ развёртывания только на чтение на приватном удалённом) или скопируйте одну эту папку.

su - vflin
git clone <the repository> vflin && cd vflin
VFLIN_S3_PUBLIC_ENDPOINT=s3.example.com VFLIN_S3_PUBLIC_USE_SSL=1 \
VFLIN_PUBLIC_URL=https://api.example.com VFLIN_TRUST_PROXY=xff \
  sh cloud/scripts/gen-secrets.sh

Это пишет cloud/.env со свежими случайными ключами (и отказывается перезаписывать существующий — измерено: второй запуск выходит с кодом 2). Замените example.com своим доменом. Три переменные перед командой — единственное различие между ноутбуком и VPS:

переменная ноутбук VPS что делает
VFLIN_S3_PUBLIC_ENDPOINT 127.0.0.1:9000 (по умолчанию) s3.<domain> хост, под который подписываются presigned-ссылки на загрузку и скачивание. Неверное значение ⇒ каждый push падает у клиента на недостижимом хосте, а сервер не пишет ничего плохого. Измерено 2026-09-04: до появления этой ручки сборка подписывала http://minio:9000/…, что не разрешит ни один демон вне контейнера
VFLIN_S3_PUBLIC_USE_SSL пусто 1 эти ссылки будут https://
VFLIN_PUBLIC_URL пусто https://api.<domain> обратные вызовы вебхуков платёжного провайдера (не используются, пока биллинг не настроен)
VFLIN_TRUST_PROXY пусто xff за Caddy/nginx/Tailscale Serve; 1 или cloudflare за Cloudflare ограничивать по адресу клиента, который передаёт прокси, а не по 127.0.0.1. xff читает самый правый X-Forwarded-For (тот переход, который дописал прокси) и ИГНОРИРУЕТ CF-Connecting-IP, который такой прокси пропускает от клиента нетронутым (Codex 130959Z #1). cloudflare (1 — значение, которое несёт живой .env, §11.5) читает сначала CF-Connecting-IP, потому что край Cloudflare пишет его сам
VFLIN_BLOB_MAX_UPLOAD_BYTES пусто 104857600 за Cloudflare Free самый большой одиночный PUT, который несёт публичный путь к объектному хранилищу; blob.upload-url отвечает на больший size кодом 413 и details.multipart, а клиент загружает по частям через blob.upload-init (§11.6)

Сделайте резервную копию cloud/.env в тот же момент, как он появился (scp его с машины или в менеджер паролей). Это единственная копия ключей; §8 говорит, чего стоит потеря каждого. Никогда не коммитьте его — он в gitignore, и перед постановкой в индекс работает scripts/check-no-secrets.sh.


4. Поднять сборку — [MEASURED locally]

docker compose -f cloud/docker-compose.yml up -d --build --wait
curl -s http://127.0.0.1:8080/v1/health

Ожидается: {"ok":true,"data":{"status":"ok","apiVersion":"0.0.1","datastores":{"postgres":true,"redis":true,"objectStore":true}}}. На машине оператора первый up занял 62 s вместе со сборкой образа; пересборка одной службы — 88 s; прогон этих же шагов с нуля в чистой папке замерен в worklog/server.md (internal: docs/90-meta/worklog/server.md). Служба пишет по одной строке JSON на запрос и по одной строке на подсистему при старте — прочитайте их один раз:

docker compose -f cloud/docker-compose.yml logs cloud | grep -v '/v1/health'

Здоровый старт печатает store: connected + migrated, sync locks: Redis backend, rate limits: Redis (shared) backend, blob custody: presigned URLs are issued for host=https://s3.<domain> и ровно один WARN — no payment provider configured, — и это правильно, пока биллинг не настроен. Любой другой WARN называет незаданную переменную; почините его, прежде чем открывать межсетевой экран.

Всё слушает на 127.0.0.1 (VFLIN_BIND в .env). Датасторы никогда не достижимы снаружи машины. Снаружи доступен только Caddy.


5. TLS через Caddy — [INTENT]

Caddy сам получает и обновляет сертификаты Let’s Encrypt, как только DNS указывает на машину. Два сайта: API и объектное хранилище, в которое демоны грузят напрямую.

# /etc/caddy/Caddyfile
api.example.com {
    reverse_proxy 127.0.0.1:8080
}

s3.example.com {
    reverse_proxy 127.0.0.1:9000
}
systemctl reload caddy
curl -s https://api.example.com/v1/health

Почему это работает и где может сломаться — чтобы это можно было проверить, а не принимать на веру: presigned S3-ссылка несёт подпись SigV4 над заголовком Host. Служба подписывает под s3.example.com (§3), демон шлёт запрос туда, Caddy пересылает его, сохраняя исходный Host (умолчание его reverse_proxy), MinIO проверяет подпись против этого хоста. TLS завершается на Caddy; схема в подпись не входит. Если загрузки падают с 403 SignatureDoesNotMatch, значит Host, доходящий до MinIO, отличается от подписанного, — проверьте, что его ничто не переписывает. Если у вас свой сертификат, замените тело сайта на tls /etc/ssl/your.crt /etc/ssl/your.key в каждом сайте.

Консоль MinIO (порт 9001) намеренно не опубликована. Дотянитесь до неё через SSH-туннель, если нужно: ssh -L 9001:127.0.0.1:9001 vflin@<vps>.


6. Доказать это с клиента — [MEASURED locally, against http://127.0.0.1:8080]

Демон читает адрес облака из своего окружения. На клиентской машине:

VFLIN_CLOUD_URL=https://api.example.com vflind          # or set it for the installed daemon
vflin keyring setup --password <passphrase>              # once per machine, if not done
vflin session unlock --password <passphrase>
vflin cloud register --email you@example.com --password <cloud-password> --device-name laptop
# S8 (2026-09-10): the account is NOT active until the 6-digit code mailed to that address is entered —
# `auth.verify-email {email, code}`; the daemon's `vflin cloud verify-email --code …` is A93's (until it lands:
# the cabinet's verify form (W6) or a curl to /v1/auth/verify-email). Then `vflin cloud login` from this
# device needs no further code — the registering device is already known to the account.
vflin keyring publish                                   # so a second device can import the keyring
vflin profile create --name first && vflin profile start <id> --headless && vflin profile stop <id>
vflin cloud push <id>

На второй машине: cloud loginkeyring import --password <passphrase>session unlockcloud onboard <id>profile start <id>. Вся последовательность, 26 шагов CLI на двух изолированных демонах, прошла зелёной за 222 s 2026-09-04; таблица шагов с кодами и таймингами — в worklog.

Две вещи, которые продукт пока не позволяет сделать из CLI или интерфейса, а сервер умеет: подключить 2FA (twofa.enroll/activate/disable — измерено зелёным через curl: вход без кода → 401 TOTP_REQUIRED, неверный код → 401 TOTP_INVALID, верный → 200) и перечислить или отозвать устройства. Это работа полосы control-plane (методы демона cloud.twofa.*, cloud.devices.*), она на доске.


7. Резервные копии — [INTENT; the commands are standard]

Состояние держат три вещи: том Postgres, том MinIO и cloud/.env. Копируйте все три; база без bucket — это список профилей, которые никто не скачает, а любая из них без .env читается, но ни одно устройство не войдёт (§8).

#!/bin/sh
# /home/vflin/backup.sh — run from cron daily; keeps 14 days
set -eu
cd /home/vflin/vflin
D=/home/vflin/backups; mkdir -p "$D"; T=$(date -u +%Y%m%dT%H%M%SZ)
docker compose -f cloud/docker-compose.yml exec -T postgres pg_dump -U vflin -Fc vflin > "$D/pg-$T.dump"
docker run --rm -v vflin-cloud_miniodata:/data:ro -v "$D":/backup alpine \
  tar czf "/backup/minio-$T.tgz" -C /data .
cp cloud/.env "$D/env-$T"
find "$D" -type f -mtime +14 -delete
# crontab -e   (as vflin)
15 3 * * * /home/vflin/backup.sh

Копируйте backups/ с машины (rsync на другую коробку или rclone в любой bucket). Восстановление на свежей машине после §2–§4 с ТЕМ ЖЕ .env:

docker compose -f cloud/docker-compose.yml stop cloud
docker compose -f cloud/docker-compose.yml exec -T postgres pg_restore -U vflin -d vflin --clean --if-exists < pg-<T>.dump
docker run --rm -v vflin-cloud_miniodata:/data -v "$PWD":/backup alpine sh -c 'cd /data && tar xzf /backup/minio-<T>.tgz'
docker compose -f cloud/docker-compose.yml start cloud

Bucket — это обычное дерево каталогов под /data; MinIO читает то, что там лежит. Обе копии — это шифртекст плюс метаданные (§0): их безопасно хранить где угодно и бесполезно иметь тому, у кого нет ключей пользователей.

Рост диска, измеренный и пока не исправленный: после двух push одного профиля bucket держал обе версии (45 KiB и 47 KiB) — фиксация не удаляет вытесненный объект, и у bucket нет правила жизненного цикла, хотя комментарии сервера говорят, что оставленное в staging «подметает lifecycle GC». Пока подметания нет, размер bucket растёт на один блоб с каждым push. Записано как остаток в worklog.


8. Обновление, откат и потеря секрета

Обновление — [BUILT]:

git pull && docker compose -f cloud/docker-compose.yml up -d --build --wait

Миграции применяются при старте (store: connected + migrated); все одиннадцать на сегодня — это CREATE … IF NOT EXISTS / ADD COLUMN IF NOT EXISTS, то есть добавляющие, поэтому откат бинарника не требует отката базы: git checkout <previous> && docker compose … up -d --build --wait. Проверьте это свойство заново в тот день, когда миграция что-нибудь удалит или переименует. Простой — это подмена контейнера, несколько секунд; экземпляр один, поэтому развёртывания без простоя нет — и в альфе оно не нужно.

Потеря секрета — что защищает каждый, написано в шапке gen-secrets.sh (internal: cloud/scripts/gen-secrets.sh); что делать:

потеряно последствие восстановление
VFLIN_JWT_SEED каждый токен доступа и обновления становится недействительным породите новое семя, перезапустите cloud; каждое устройство снова делает vflin cloud login. Ничего сохранённого не теряется
VFLIN_TOTP_KEY каждое подключённое семя TOTP нечитаемо ⇒ ни один аккаунт с включённой 2FA не войдёт (сервер требует код, который больше не может проверить) чёрного хода нет по замыслу. Убедившись в личности пользователя вне системы, оператор с доступом к базе снимает фактор, и пользователь подключает его заново: UPDATE accounts SET totp_enabled=false, totp_seed_enc=NULL WHERE email='…'; затем породите новый ключ и перезапустите
VFLIN_LOGIN_INIT_SECRET меняются приманочные соли для неизвестных адресов; и каждый отправленный код регистрации или нового устройства, ещё летящий, перестаёт проверяться (S8: коды хранятся как HMAC под этим секретом, живут 15 минут) безвредно; породите новый, перезапустите — тот, кто в середине регистрации, попросит свежий код (auth.resend-code)
VFLIN_PG_PASSWORD служба не может подключиться значение в .env применяется только при первой инициализации тома; чтобы ротировать: ALTER ROLE vflin PASSWORD '<new>'; через psql, обновите .env, перезапустите cloud
VFLIN_S3_SECRET_KEY служба не может подписывать и опрашивать блобы корневые учётные данные MinIO — это его окружение: поменяйте значение в .env, up -d пересоздаст minio с новым корневым паролем (данные останутся), перезапустите cloud
парольная фраза keyring’а пользователя и его ключ восстановления профили этого пользователя невосстановимы никак. У сервера их никогда не было (§0). Говорите это пользователям при регистрации

9. Что отвечает 503 или недостижимо, пока не настроено — [MEASURED, all 40 contract routes]

Прощупано валидным токеном против контейнера, у которого была настроена только база данных:

без чего маршруты, отвечающие 503 UNAVAILABLE
VFLIN_LOGIN_INIT_SECRET auth.login-init — значит, ни одно устройство не войдёт (регистрация всё ещё работает). Fail-closed намеренно: пустой ключ HMAC сделал бы приманочную соль угадываемой, а эндпоинт — оракулом перечисления аккаунтов
VFLIN_TOTP_KEY twofa.enroll, twofa.activate, twofa.disable
VFLIN_S3_ENDPOINT blob.uploadUrl, blob.commit, blob.downloadUrl, blob.head, sync.push
платёжного провайдера billing.checkout (503); billing.webhook отвечает 404
Redis ничего — синхронизационные блокировки и ограничитель откатываются на Postgres и на внутрипроцессный счётчик

Всё остальное ответило своим настоящим кодом (200, 400, 401, 404, 409). С полным .env из §3 ни один маршрут не отвечает 503. Десять из сорока маршрутов реализованы на сервере, но ни один метод демона их не зовёт: twofa.* (3), device.list/device.revoke, log.append/log.list, billing.checkout, billing.webhook (по природе серверный), cloud.health (демон никогда не спрашивает, поднято ли облако, — vflin cloud status сообщает только о локальной сессии).


10. Чего здесь нет

Один узел, никакого резервирования, никакого мониторинга сверх docker compose ps и маршрута здоровья, никакой отгрузки логов, никакой настройки ограничителей, никакого R2. Всё это — после альфы; устройство это позволяет (служба без состояния, ограничитель, разделяемый через Redis, хранение, совместимое с S3), и ничего из этого не запускалось.


11. Этот ПК как сервер — S3, 2026-09-04

Владелец решил 2026-09-05, что сервер альфы — эта машина, а не арендованная. Это меняет три вещи в §1–§5 и оставляет остальное в силе: машина уже оплачена, она стоит за домашним роутером без публичного адреса и перезагружается, когда так решит Windows. Этот раздел — то, что на ней измерено; путь с VPS выше остаётся верным на тот день, когда VPS арендуют.

11.0 Достижимость — три способа, выбран один

способ что открыто в интернет у кого TLS что должен сделать владелец цена
Tailscale Serve (выбран) ничего — достают только устройства, вошедшие в tailnet владельца Tailscale выпускает сертификат Let’s Encrypt для <machine>.<tailnet>.ts.net; обновление — их забота ничего нового: tailnet здесь уже есть, в нём шесть устройств, и эта машина уже держит Funnel для двух других служб (:443, :8443) $0
Tailscale Funnel выбранные порты, любому то же переключить Serve в Funnel на одном порту $0 — но Funnel разрешает только 443/8443/10000, два заняты службами владельца, а API и объектному хранилищу нужны два публичных имени. Так что Funnel может опубликовать публике API, но не хранилище
Cloudflare Tunnel ничего (туннель наружу), сколько угодно имён под доменом Cloudflare аккаунт Cloudflare, домен в нём, cloudflared tunnel login; cloudflared 2026.5.2 здесь установлен, аккаунта нет цена домена
Проброс порта + DDNS + Caddy 80/443 на домашнем роутере, проброшенные сюда Caddy (Let’s Encrypt), §5 настройка роутера, имя DDNS и согласие держать открытый порт в домашней сети DDNS $0–$3/мес

Выбрано: Tailscale Serve, только внутри tailnet. Это соответствует форме альфы — собственные устройства владельца — и это измеримо, ничего у владельца не прося, в том tailnet, который у него уже работает. Это ещё и самый приватный из четырёх: у сервера синхронизации вообще нет публичной поверхности. В тот момент, когда до него должно дотянуться устройство НЕ из tailnet, ответ — Cloudflare Tunnel (два имени, никаких открытых портов), и команды лежат в 11.5; Funnel может нести только API.

Что сделано [MEASURED] — две записи Serve на портах, которыми никто больше не пользовался, Funnel владельца не тронут:

tailscale serve --bg --https=8081 http://127.0.0.1:8080     # the API
tailscale serve --bg --https=9443 http://127.0.0.1:19000    # the object store (this machine's MinIO port)
tailscale serve status                                        # shows both as "(tailnet only)"
# undo either: tailscale serve --https=8081 off

и в cloud/.env (затем docker compose … up -d --wait cloud):

VFLIN_S3_PUBLIC_ENDPOINT=<tailnet-host>:9443
VFLIN_S3_PUBLIC_USE_SSL=1
VFLIN_TRUST_PROXY=xff      # Serve forwards the client address in X-Forwarded-For (S7: `1` now means Cloudflare — CF-Connecting-IP first)

Демону на другом устройстве тогда нужен VFLIN_CLOUD_URL=https://<tailnet-host>:8081. Измерено с этого хоста через tailscaled (тот же путь, которым идёт узел tailnet):

проверка результат
https://…:8081/v1/health 200, сертификат действителен (ssl_verify_result=0), 35 ms
https://…:9443/minio/health/live 200, сертификат действителен
свежий демон: keyring setupcloud registerprofile create/start/stopcloud push ×2 → cloud list, всё по имени в tailnet 12 шагов, у всех rc=0; каждый push 160–170 ms вместе с presigned PUT на https://…:9443

[INTENT], по одной строке: то же со второго устройства tailnet (dcrypt владельца в сети) — curl https://<tailnet-host>:8081/v1/health с него; цепочка сертификатов будет той же, а маршрут не пойдёт через loopback этого хоста. Здесь не сделано, потому что второго хоста в этой сессии нет.

11.1 Автозапуск — измерено, и один флаг за владельцем

факт как прочитано значение
политика перезапуска каждого контейнера docker inspect … RestartPolicy unless-stopped ×4 — движок поднимает их всякий раз, когда стартует сам
сборка после docker compose downup -d --wait замерено, четыре раза на этой неделе 19–62 s, здоровье ok
Docker Desktop стартует вместе с Windows %APPDATA%\Docker\settings-store.jsonAutoStart false — то есть после перезагрузки ничего не запускается, пока Docker Desktop не откроют руками
служба Windows, бэкенд Docker sc qc com.docker.service DEMAND_START, остановлена — Docker Desktop здесь программа пользователя, а не служба

Значит, история с перезагрузкой — в одном флаге, и флаг этот в программе владельца: Docker Desktop → Settings → General → «Start Docker Desktop when you sign in» (он пишет "AutoStart": true в файл выше). Эта сессия его не переключала — это настройка программы владельца, — и сама перезагрузка не делалась (машина владельца, его момент). [INTENT]: после флага — shutdown /r, вход в систему, минута ожидания, docker compose -f cloud/docker-compose.yml ps показывает четыре Up, а tailscale serve status по-прежнему перечисляет оба порта (конфигурация Serve переживает перезагрузки; её хранит tailscaled).

Два честных предела «стартует вместе с Windows» на настольной машине: контейнеры возвращаются только после того, как кто-то вошёл в систему (Docker Desktop работает в сеансе пользователя); и Windows Update перезагружает машину по собственному расписанию — сервер синхронизации лежит с этой перезагрузки до следующего входа. Демон, который не может достучаться до сервера, показывает состояние unreadable на экране облака, и ничего не теряется: push повторяют вручную, профили остаются локальными.

11.2 Резервные копии — скрипт, расписание и восстановление, которое действительно сделали

cloud/scripts/backup.sh пишет один датированный набор — pg-<T>.dump (свой формат), minio-<T>.tgz (том bucket, упакованный tar только на чтение временным контейнером, пока хранилище продолжает обслуживать), env-<T> (ключи) — в VFLIN_BACKUP_DIR из .env, удаляет наборы старше VFLIN_BACKUP_KEEP_DAYS (14) и дописывает одну строку в backup.log там же. backup.cmd — точка входа для Windows (Git Bash). Здесь: D:/VFlin_Project/_backups/vflin-cloud — другой диск, не системный (538 GB свободно против 55 на C:).

schtasks /Create /TN VflinCloudBackup /SC DAILY /ST 03:15 /TR "\"D:\VFlin_Project\Vflin Browser\cloud\scripts\backup.cmd\"" /F
schtasks /Run /TN VflinCloudBackup        # prove it once
schtasks /Delete /TN VflinCloudBackup /F  # undo

[MEASURED] задача создана (ежедневно в 03:15, работает от вошедшего пользователя — Docker Desktop достижим только там, так что «запускать независимо от входа пользователя» не нашёл бы docker), один раз запущена через планировщик: Last Result 0; набор: дамп 103 818 B, bucket 408 514 B, env 1 531 B; строка в журнале появилась.

Восстановление, доказанное на чистой сборке [MEASURED]: cloud/scripts/restore.sh <T> s3restore <env-with-other-ports> поднял вторую сборку рядом с живой из ТЕХ ЖЕ ключей, положил в неё дамп и bucket, запустил службу — 33 s, здоровье ok, 267 аккаунтов в восстановленной базе, — а свежий демон, направленный на неё, вошёл под аккаунтом, который S2 создал днём раньше, и перечислил профиль этого аккаунта (39 319 B, отметка из дампа). Затем down -v. Чего эта репетиция показать не могла: что аккаунт, созданный после копии, отсутствует — каждый аккаунт на машине старше набора; следующая репетиция после настоящей работы должна проверить и это.

11.3 Bucket больше не растёт на один блоб с каждым push — [MEASURED]

S1 нашёл две утечки: фиксация никогда не убирала объект, который вытеснила, и у bucket не было правила жизненного цикла, хотя комментарии кода на него опирались. Обе закрыты в сервере (cloud/internal/blob, api/blob.go, api/sync.go, store/profile.go):

  • две функции фиксации в хранилище возвращают blob_key, с которого ушёл указатель, прочитанный под тем же замком и снимком, что и запись; обработчик удаляет этот объект после фиксации метаданных, никогда до и никогда, если он равен новому ключу (повторный push того же содержимого перезаписал бы его). Закреплено двумя тестами с поддельным хранением, которое записывает порядок вызовов, — красные при выключенном подметании (second commit never removed the superseded …), зелёные с ним, — и интеграционным набором;
  • вся последовательность — Promote, переброс указателя, подметание — идёт под одним писателем на профиль (store.LockProfile, консультативная блокировка Postgres). Независимое чтение первой версии от Codex нашло гонку, которую это закрывает: зафиксированные ключи адресуются содержимым и потому воссоздаваемы, так что «зафиксировать X→Y, припарковать подметание X, зафиксировать Y→X, продолжить» удаляло объект, к которому указатель только что вернулся. Тест строит ровно это переплетение с припаркованным поддельным удалением; без блокировки он падает детерминированно. Второе чтение нашло ещё две, обе исправлены в тот же день: блокировка берётся до продления аренды и сравнения версий (два push могли пройти одну и ту же проверку и позволить устаревшему зафиксироваться последним), и ожидающий не держит соединения из пула, пока ждёт (pg_try_advisory_lock опрашивается каждые 25 ms — блокирующая форма позволяла N push одного клиента по одному профилю исчерпать пул и подвесить всё; тест на пуле из двух доказал зависание);
  • загрузки в staging живут под staging/ (раньше это было …/<hash>.staging — суффикс, который не выбирает ни одно правило жизненного цикла), и служба ставит одно правило при старте: истекать staging/ через сутки. Прочитано обратно из MinIO: expire-staged-uploads · Enabled · staging/ · 1 day. Хранилище, которое отказывает в правиле, получает WARN, а не отказ стартовать.

Инвентаризация после двух push одного профиля по tailnet: 1 объект, staging/ пуст. Три объекта, которые оставили прогоны S1 (две версии одного профиля и один осиротевший .staging), всё ещё там — исправление не подметает прошлое, и оператор, который хочет от них избавиться, убирает их однажды руками (mc rm).

11.4 Что видит сервер — без изменений

Прочитано из базы после прогона, как делали S1 и S2: новая строка аккаунта (адрес почты, argon2id-хэш верификатора, 16-байтная соль, totp_enabled=false), одно устройство, одна строка profiles_meta, теперь указывающая на единственный объект в bucket для этого профиля, ноль строк аудита. Ничто, добавленное этой сессией, не читаемо серверу сверх того, что было; слой Tailscale завершает TLS на этом хосте, внутри tailnet владельца, и пересылает на loopback.

11.5 Устройство вне tailnet до него дотягивается — [MEASURED] (S7, 2026-09-10)

Что владелец сделал 2026-09-10 (GOING_PUBLIC_OWNER.md (internal: docs/00-product/GOING_PUBLIC_OWNER.md) §2–3): туннель Cloudflare vflin-pc, установленный как служба Windows (cloudflared … tunnel run, автозапуск, вход не нужен), два публичных имени — api.vflin-antik.com127.0.0.1:8080 и s3.vflin-antik.com → MinIO на 127.0.0.1:19000 — и четыре строки в cloud/.env: VFLIN_PUBLIC_URL=https://api.vflin-antik.com, VFLIN_TRUST_PROXY=1, VFLIN_S3_PUBLIC_ENDPOINT=s3.vflin-antik.com, VFLIN_S3_PUBLIC_USE_SSL=1; затем up -d --build. На роутере не открыт ни один порт; TLS завершает Cloudflare; записи Tailscale Serve из 11.0 остались как были. Блок [INTENT], который здесь стоял (Funnel или cloudflared tunnel create …), был вторым из двух его вариантов, сделанным через панель, а не через CLI.

Измерено с этого ПК через публичные именаcurl и облачный клиент разрешают имена через интернет, доходят до края Cloudflare, а туннель приносит запрос обратно на этот хост; ничто здесь не использует tailnet:

шаг результат
GET https://api.vflin-antik.com/v1/health ok, apiVersion 0.0.3, три датастора true
register → login-init → login → keyring put → lock acquire → blob.upload-url (подписан под https://s3.vflin-antik.com/…) → PUT → push → pull со второго устройства → download → байты сравнились равными VFLIN_CLOUD_URL=https://api.vflin-antik.com cargo test -p vflin-cloudclient --test roundtrip -- --ignored: 1 passed, 2.02 s
кабинет сайта и демон против публичного origin cabinet-truth 35 из 35 (47.3 s) · cross-login 21 из 21 (20.8 s) на финальной сборке S7 (web/scripts/, SITE_URL=https://vflin-antik.com API_URL=https://api.vflin-antik.com)
адрес, на который ключуется ограничитель, для вызова с этого ПК client в журнале запросов равен исходящему адресу этого ПК (сравнено скриптом, никогда не напечатано — ISSUE-138): CF-Connecting-IP переживает туннель; самый правый X-Forwarded-For — это собственный переход туннеля, из-за чего S6 назвала это блокером альфы
то же для вызова через собственный /v1 сайта (прокси в Pages Function) client — это исходящий адрес Cloudflare Workers (IPv6), а не посетителя: Cloudflare переписывает CF-Connecting-IP на краю зоны api для подзапроса Worker’а, так что каждый посетитель кабинета, идущий через сайт, делит ОДИН адрес-ключ там. Окна на адрес почты и на аккаунт по-прежнему держатся; окна на адрес на этом пути общие. Названо координации в handover S7
второе физическое устройство вне tailnet (телефон без Wi-Fi, другой ПК) не измерено — в сессии его не было. Обе точки обзора выше уходят с этого хоста, пересекают Cloudflare и возвращаются через туннель, так что публичный путь доказан; чем добавил бы телефон — тем, что его ключ отличался бы от ключа этого ПК, и это один curl, когда у владельца он под рукой
S9, 2026-09-11 — маршрут кабинета с W6: этот ПК напрямую в api., с Origin сайта GET /v1/health200 с Access-Control-Allow-Origin: https://vflin-antik.com и Vary: Origin; предзапрос OPTIONS /v1/auth/login-init204 с разрешёнными методами; чужой Origin200 без разрешающего заголовка (браузер оставляет ответ на той странице). client в журнале запросов для прямого вызова равен исходящему адресу этого ПК, как его сообщает край (/cdn-cgi/trace, сравнено в памяти, никогда не напечатано). Предзапрос, отправленный с User-Agent по умолчанию из Python, получает 403 от самого Cloudflare — без разрешающего заголовка, до службы не доходит, — с чем браузер не сталкивается никогда
S9 — второй АДРЕС для того же измерения по-прежнему не измерен, и почему: у этого ПК нет маршрута IPv6 до Cloudflare (curl -6 код выхода 6), а проба через WebFetch в Claude Code уходила с того же исходящего адреса (её client равнялся адресу этого ПК) — он работает на этой же машине. Два разных посетителя, ключуемые как два клиента, требуют второй сети: телефона владельца, бриф W7

11.6 Публичный путь к хранилищу несёт не больше 100 MiB за PUT — и любой профиль по частям — [MEASURED] (S7, S9)

Cloudflare Free режет тело запроса свыше 100 MiB, а s3.vflin-antik.com стоит за ним. Измерено 2026-09-10 presigned-запросами PUT с этого ПК:

тело ответ
99 MiB (103,809,024 B) 200, 6.2 s на 16.9 MB/s
100,000,000 B 200
104,857,600 B (100 MiB) 200, 5.3 s
104,857,601 B 413 Payload Too Large — собственная HTML-страница Cloudflare, через 0.45 s, после того как от клиента ушло ~1.1 MB
101 MiB · 120 MiB 413, тем же образом

Значит, предел — ровно 104857600 байт, и без помощи клиент узнаёт о нём из страницы прокси посреди загрузки, а не от сервера. Что делает сервер: VFLIN_BLOB_MAX_UPLOAD_BYTES=104857600 стоит в .env (строка владельца, после S7), поэтому каждый presign называет maxBytes (клиент, который не шлёт size, 0.0.2, может сравнить до начала), а blob.upload-url, вызванный с size сверх предела, отвечает 413 PAYLOAD_TOO_LARGE — и с контракта 0.0.5 (S9) это больше не тупик: ошибка несёт details: {multipart: true, maxBytes: 104857600, partSize: 52428800}, а её слова указывают на blob.uploadInit.

Путь в обход предела — multipart через presigned-части, [MEASURED] (S9, 2026-09-11). blob.upload-init {contentHash, size} открывает multipart-загрузку S3 на ТОМ ЖЕ staged-ключе, что и одиночная загрузка (staging/<profileId>/<contentHash>), и возвращает по одной presigned-ссылке UploadPart на часть, подписанной под публичным именем; клиент кладёт каждую часть прямо в хранилище (байты, как и раньше, не проходят через прикладной уровень) и сохраняет заголовок ETag из ответа каждой части; blob.upload-complete {contentHash, uploadId, parts: [{partNumber, etag}]} собирает их (нумерация 1..N по порядку; пропущенная часть, неверный ETag, истёкшая или прерванная загрузка либо id загрузки, открытый для другого ключа → 409 FAILED_PRECONDITION, начинайте заново). Затем blob.commit / sync.push продвигают staged-объект ровно так же, как после одиночной загрузки, — ниже по течению не изменилось ничего. Числа и почему они такие:

ручка значение почему
размер части 50 MiB (52428800; последняя часть — остаток) половина измеренного предела прокси; у S3 нижняя граница 5 MiB, верхняя — 10 000 частей: потолок службы в 8 GiB на профиль — это 164 части
время жизни ссылки на часть 2 h (expiresIn: 7200) более медленный клиент открывает новую загрузку
незавершённая загрузка прерывается через 6 h подметанием службы (каждые 15 min, рядом с подметанием кодов из писем) правило жизненного цикла staging истекает ОБЪЕКТЫ в staging, а не открытые загрузки; части открытой загрузки занимают bucket, пока их что-нибудь не прервёт
продвижение свыше 5 GiB ComposeObject (UploadPartCopy) вместо одного CopyObject S3 копирует за один запрос не больше 5 GiB, а multipart-загрузка теперь может положить в staging больше

Одна находка, специфичная для хранилища, измеренная на MinIO этой сборки: ListMultipartUploads находит открытую загрузку по её точному ключу или по ПУСТОМУ префиксу и возвращает ничего для частичного префикса вроде staging/<profileId>/ — поэтому подметание перечисляет все открытые загрузки в bucket и фильтрует по префиксу само; спросить хранилище о префиксе значило бы не прервать ничего, молча.

Измерено через s3.vflin-antik.com с этого ПК (TestLiveMultipartThroughThePublicObjectStoreName, под гейтом: API в процессе поверх временного Postgres — на публичной базе аккаунта нет, — а хранение на MinIO ЭТОЙ сборки со ссылками, подписанными под публичным именем, так что каждый байт прошёл путь клиента):

шаг ответ
blob.upload-url с size = 120 MiB 413 PAYLOAD_TOO_LARGE, details.multipart = true, maxBytes 104857600, partSize 52428800
контроль: ОДИН PUT тех же 125 829 120 байт по тому же маршруту 413 от прокси за 0.24 s — проходит именно из-за частей
blob.upload-init 3 части: 50 MiB, 50 MiB, 20 MiB; ссылки действительны 7200 s, каждая https:// под публичным именем
три PUT 200 у каждого с ETag: 1.52 s (34.6 MB/s) · 1.47 s (35.6 MB/s) · 0.66 s (31.7 MB/s) — загрузка суммарно 3.67 s, 34.3 MB/s
blob.upload-complete · blob.commit 200 за 16 ms (собрано, 125 829 120 байт) · 200 за 400 ms (копия на стороне сервера в зафиксированный ключ)
blob.download-urlGET через публичное имя 125 829 120 байт за 6.61 s (19.0 MB/s), SHA-256 равен загруженному
загрузка открыта, одна часть отправлена через публичное имя, завершения не было подметание прервало её на настоящем хранилище (1); blob.upload-complete после этого → 409 FAILED_PRECONDITION
что осталось ничего: тест убирает свой зафиксированный объект и прерывает всё открытое под своим id профиля; проверено после на томе — s9-* в blob/ и staging/: 0, открытых multipart-загрузок: 0

Клиент демона для этого пути — за полосой control-plane (A93+); пока он не приземлится, профиль больше 100 MiB всё ещё не синхронизируется из демона — серверная половина сделана и живёт.

11.7 В публичной базе снова только настоящие аккаунты — [MEASURED] (S10, 2026-09-11)

База сборки заполнилась собственными аккаунтами интеграционного набора на Go: 1 643 из 1 644 аккаунтов оказались строками проб (…@example.com / …@example.test), и локальные части называют тесты, которые их сделали, — twofa 66, blob 44, sweep 34, rt 24, kr 24, dup 23, rot/rotm 44, plist 22, reset 20, — то есть набор гоняли с VFLIN_TEST_DATABASE_URL, указывающим на ПУБЛИЧНУЮ сборку, в четыре дня (551 за 2026-09-04, 774 за 09-05, 310 за 09-10, 8 за 09-11). Им принадлежали 1 662 из 1 663 устройств, все 768 указателей на профили (заявлявших 11 811 725 936 байт) и 27 конвертов keyring.

Удалено по правилу S8 — сначала посчитать, удалять ТЕМ ЖЕ WHERE в скобках, и транзакция отказывается, если число не совпало:

SELECT count(*) FROM accounts WHERE (email LIKE '%@example.com' OR email LIKE '%@example.test');   -- 1643
DO $$ DECLARE got bigint; BEGIN
  DELETE FROM accounts WHERE (email LIKE '%@example.com' OR email LIKE '%@example.test');
  GET DIAGNOSTICS got = ROW_COUNT;
  IF got <> 1643 THEN RAISE EXCEPTION 'expected 1643, removed % - rolling back', got; END IF;
END $$;                                                                                            -- NOTICE: 1643
до после
аккаунты (в форме проб / прочие) 1 644 (1 643 / 1) 1 (0 / 1)
устройства 1 663 1
указатели на профили 768 0
конверты keyring · живые коды из писем 27 · 0 0 · 0
bucket blob/ 18 объектов, 643 908 B 0
bucket staging/ 1 объект, 100 000 000 B 0

billing_events (61) сохраняют свою историю со ссылкой на аккаунт, выставленной в NULL, — так задумано (миграция 0012); password_resets (11) — это строки-приманки без аккаунта, их вычистит следующий reset-init. Единственный аккаунт, который не является строкой проб, не тронут: он был 1 до и остался 1 после, и теперь это единственная строка в таблице.

Объекты. Блобы удалённого аккаунта не возвращает ни одно подметание — runSweeps убирает неподтверждённые аккаунты, истёкшие коды и брошенные multipart-загрузки, а account.delete — единственный путь, который убирает объекты (после указателя, никогда до). Поэтому указатели прочитали ДО удаления, а их объекты убрали после, именно в таком порядке: из 768 указателей объект ещё был лишь у 15 (753 давно подмело подметание вытеснения из S3), а после этих 15 остались три объекта под blob/ и один в staging, которых не называл ни один указатель, — их убрали тоже, раз profiles_meta к тому моменту была пуста. 0 сирот. Объектом в staging оказалось тело на 100 000 000 байт от измерения предела в S7, всё ещё лежавшее там примерно 1.5 суток спустя, хотя правило жизненного цикла для staging ставится при старте (строка журнала об этом говорит): сканирование ILM у MinIO его не применило — стоит знать, прежде чем полагаться на это правило для уборки.

Что делать вместо прогона набора здесь. Интеграционные тесты принимают VFLIN_TEST_DATABASE_URL / VFLIN_TEST_S3_ENDPOINT; направляйте их на временный стек (docker compose -p vflin-s10test -f cloud/docker-compose.yml up -d --wait postgres redis minio с переменными портов хоста), никогда на собственные порты сборки. Гейт, который откажет тесту под гейтом на публичное имя, — это долг control-plane (A94).

12. Telegram-панель владельца — только чтение — [MEASURED] (S11, 2026-09-12)

Второй контейнер в той же сборке, tgbot, чтобы владелец видел с телефона, жив ли сервер, и мог ответить на вопрос поддержки, не открывая терминал. Он ничего не слушает: его единственное сетевое действие — исходящий длинный опрос api.telegram.org, так что сборка не открывает под него ни одного порта и никакого webhook защищать не нужно. Он отвечает одному chat id и молча роняет всё остальное — ответ, даже отказ, сообщает постороннему, что бот здесь есть. И он читает базу под ролью vflin_ro (миграция 0019), у которой есть SELECT и больше ничего.

Насколько крепко это «не может писать», зависит от того, как панель входит в эту роль, а это две разные вещи (находка Codex 2026-09-12, номер 1, закрытая в той же сессии):

как соединяется что отказывает записи что говорит журнал старта
VFLIN_TG_DATABASE_URL — логин, который состоит в vflin_ro и ничем не владеет (§12.4) база данных, для этого соединения, навсегда “cannot write even after RESET ROLE — the refusal is the database’s”
ничего не задано: собственный DSN службы + SET ROLE vflin_ro (по умолчанию) дисциплина этого кода — один RESET ROLE возвращает сессию логину, который писать может предупреждение, называющее, как это чинится

Панель измеряет, что именно ей досталось (ProbeRoleEscape), и печатает это при каждом старте, так что более сильное утверждение никогда не произносится на более слабой настройке. TestTheRoleEscapeProbeTellsADedicatedLoginFromSetRole закрепляет обе половины.

12.1 Что владелец делает один раз

# 1. Make the bot: Telegram → @BotFather → /newbot → a name → the token it prints.
# 2. Put the token in cloud/.env (this file is never committed; the deploy reads it):
#    VFLIN_TG_BOT_TOKEN=123456:AA...          <- from @BotFather
# 3. Start the panel (naming the service enables its profile; a plain `up -d` leaves it alone):
docker compose -f cloud/docker-compose.yml up -d tgbot
# 4. Write /start to the bot. With VFLIN_TG_OWNER_CHAT_ID still empty it answers ONE thing — that chat's own
#    id — and nothing else. Put that number in cloud/.env and restart the panel:
#    VFLIN_TG_OWNER_CHAT_ID=123456789
docker compose -f cloud/docker-compose.yml up -d tgbot

Дальше он отвечает только этому чату. У остальных ручек рабочие значения по умолчанию: VFLIN_TG_DISK_FLOOR_GB (10), VFLIN_TG_DIGEST_HOUR (9, местное время хоста), VFLIN_TG_DB_ROLE (vflin_ro), TZ (Europe/Moscow). У одной ручки значения по умолчанию нет, и она стоит этих двух минут: VFLIN_TG_DATABASE_URL — §12.4.

12.2 Что он отвечает

команда что приходит в ответ
/stats служба (ok, версия контракта, три хранилища), аккаунты (всего / подтверждённых), устройства (всего / выходивших на связь за 24 h), профили и байты, которые заявляют их указатели, bucket (объекты, байты — обновляется не чаще раза в 10 min), регистрации за 24 h и 7 d, живые письма с кодами, тарифы, сколько окон ограничения считают прямо сейчас, и свободное место на томах
/find <address or id> создан, подтверждён, тариф, второй фактор, версия верификатора, устройства (сколько и сколько из них помнят свой секрет), последняя связь, профили и байты, открыт ли код и когда он истекает, и какие окна ограничения держат адрес сейчас
/devices <address or id> по каждому устройству: имя, когда добавлено, последняя связь, установлена ли сессия, помнит ли оно свой секрет
/recent [n] последние n регистраций, адреса замаскированы (a***@domain); /recent full печатает их целиком — второй, сознательный шаг
/help список и фраза о том, что эта панель ничего не может изменить

Сообщения, которых не просили: служба упала / вернулась (и сколько её не было), свободного места меньше порога (не чаще одного предупреждения в 6 h) и суточная сводка в VFLIN_TG_DIGEST_HOUR.

12.3 Чего он никогда не печатает и чего не видит

Никогда: хэш пароля, соль входа, refresh-токен, секрет устройства, HMAC кода — и не значение кода из письма, которое является секретом и от владельца тоже (это то, что его получатель вводит, доказывая, что ящик его). Панель говорит, что код открыт и когда он истекает. Запросы называют свои столбцы, и секретных столбцов среди них нет; тест прогоняет каждый ответ, который панель способна породить, — отрисованный из НАСТОЯЩЕЙ строки, где всё это есть, — на формы, которые здесь принимает секрет.

Не видит: диск хоста. Панель измеряет файловую систему, на которой живут тома Docker, изнутри своего контейнера. На этой машине это диск с данными Docker, и происшествие из S10 — Windows C: на 100 %, демон заклинило на сорок минут — эту тревогу НЕ подняло бы, потому что том о нём ничего не знал. Если владелец хочет следить за диском хоста, примонтируйте путь хоста в tgbot и добавьте его в VFLIN_TG_DISK_PATHS; том Docker не может отвечать за диск под самим Docker.

12.4 Дайте панели собственный логин — форма, в которой отказывает база

Две строки SQL и одна переменная. Пароль владелец придумывает сам, и он никогда не покидает cloud/.env; ничто в этом репозитории его не создаёт, не угадывает и не печатает, а миграция 0019 сознательно оставляет vflin_ro NOLOGIN, чтобы за владельца не придумали учётные данные.

# In psql on the cloud database, as the owner of the schema:
#   CREATE ROLE vflin_tg LOGIN PASSWORD '<a password you invent>' IN ROLE vflin_ro;
# Then in cloud/.env (the DSN differs from VFLIN_DATABASE_URL only in the user and password):
#   VFLIN_TG_DATABASE_URL=postgres://vflin_tg:<that password>@postgres:5432/vflin?sslmode=disable
docker compose -f cloud/docker-compose.yml up -d tgbot
docker compose -f cloud/docker-compose.yml logs tgbot | grep -i "refusal is the database"

Последняя строка и есть суть: панель печатает, какая форма ей досталась. vflin_tg ничем не владеет и лишь состоит в vflin_ro, поэтому RESET ROLE оставляет его ровно там, где он начал, — запись, которая ему понадобилась бы, это permission denied на стороне базы, а не то, чего этот код избегает сам. Оставьте VFLIN_TG_DATABASE_URL пустым — панель всё равно работает и всё равно читает через vflin_ro; она просто предупреждает при каждом старте, что режим только для чтения тогда держится на её собственной дисциплине.

12.5 Чего он делать не будет

Ни одно действие ничего не меняет: ни одно устройство не отозвано, ни один тариф не продлён, ни один код не отправлен заново. Пути записи в панели нет вовсе — до него не доводит ни одна команда, — а под выделенным логином из §12.4 в записи откажет и база. Так и останется, пока не появится admin API с шагом подтверждения (S12). И ещё одно, что владельцу стоит знать, сказанное прямо: переписка идёт через Telegram, поэтому Telegram видит сводки и любой адрес, который владелец запросил целиком. Это панель владельца по решению владельца, а не поверхность продукта для пользователей.

12.6 Два других хранилища — Redis и bucket (ISSUE-203)

Postgres отказывает панели в записи из-за GRANT (§12.4). У Redis и объектного хранилища такой границы не было вовсе: панели отдавали собственный адрес Redis службы и полные ключи S3, и в рамках SCAN/ZCARD/TTL и листинга её держал только её собственный код. Два рецепта это закрывают, а панель измеряет, в каком состоянии она оказалась, и печатает это при каждом старте — она никогда не заявляет границу, которую не проверила.

# --- Redis: an ACL user that can read the rate-limit windows and nothing else ---
# In redis-cli on the deployment's Redis:
#   ACL SETUSER vflin_panel on >'<a password you invent>' ~rl:w:* +scan +zcard +ttl +ping +info
#   ACL SAVE            # only if this Redis has an aclfile; otherwise put the line in redis.conf
# Then in cloud/.env:
#   VFLIN_TG_REDIS_USER=vflin_panel
#   VFLIN_TG_REDIS_PASS=<that password>

# --- MinIO: a user with a read-only policy on the blob bucket ---
#   mc alias set local http://127.0.0.1:9000 <admin key> <admin secret>
#   mc admin user add local vflin_panel '<a password you invent>'
#   mc admin policy attach local readonly --user vflin_panel
# Then in cloud/.env:
#   VFLIN_TG_S3_ACCESS_KEY=vflin_panel
#   VFLIN_TG_S3_SECRET_KEY=<that password>

docker compose -f cloud/docker-compose.yml up -d tgbot
docker compose -f cloud/docker-compose.yml logs tgbot | grep "store boundary"

И снова суть в последней строке. INFO … запись запрещает сам стор означает, что отказывает хранилище; WARN … ЗАПИСЬ РАЗРЕШЕНА этими ключами — что её сдерживает только код панели, и тут же названо, чем это чинится.

Пробы по устройству ничего не пишут — знать это стоит, потому что проба, которая пишет, чтобы выяснить, может ли она писать, уже сделала ровно то, чего панель делать не должна. Проба Redis выполняет SET … XX по отсутствующему ключу: ACL проверяется до выполнения команды, а XX делает команду ничем, когда ключа нет. Проба объектного хранилища просит УДАЛИТЬ ключ, который никогда не записывался, в настоящем bucket: удаление отсутствующего объекта ничего не меняет и отвечает успехом, а идентичность только для чтения получает отказ ещё до выполнения. (На bucket с ВКЛЮЧЁННЫМ версионированием такое удаление оставило бы маркер удаления, поэтому версионирование читается первым, и bucket с включённым версионированием сообщается как не измерено, а не пробуется.)

Первая версия пробы объектного хранилища была неверной, и поправка — причина, по которой этот абзац такой подробный. Она просила PUT в bucket, которого не существует, исходя из того, что права проверяются раньше существования. Измерено против MinIO RELEASE.2025-08-13: это не так — пользователь с политикой readonly получает NoSuchBucket, то есть панель объявила бы запись разрешена про идентичность, которая писать не может. Обращение к несуществующему bucket по-прежнему делается первым, потому что стоит оно ничего и ловит ключи, которых хранилище не знает вовсе, — но оно способно только ПОДТВЕРДИТЬ отказ, а не заключить, что права есть.

Обе пробы затем измерены против настоящего Redis и настоящего MinIO с идентичностями выше — и каждая с контролем на полных ключах, потому что проба, которая не умеет распознать разрешающий случай, ничего не доказывает про ограниченный. Что сказал журнал старта в обеих конфигурациях:

A — a fresh deployment, the service's own credentials everywhere
  WARN  the panel enters vflin_ro from a login that MAY write: `RESET ROLE` would restore write rights…
  WARN  объектное хранилище: ЗАПИСЬ РАЗРЕШЕНА этими ключами — только код панели держит её в рамках чтения…
  WARN  Redis: ЗАПИСЬ РАЗРЕШЕНА этими ключами — только код панели держит её в рамках чтения…

B — §12.4 and §12.6 done: three restricted identities
  INFO  the panel's database session cannot write even after RESET ROLE — the refusal is the database's
  INFO  объектное хранилище: запись запрещает сам стор (отказ по правам: AccessDenied)
  INFO  Redis: запись запрещает сам стор (отказ по ACL: NOPERM)

И ограниченные идентичности по-прежнему делают работу панели: пользователь с ACL читает окна ограничений, S3-пользователь только для чтения снимает статистику bucket. Граница, которая заодно блокирует работу, — не решение.

12.7 Измерено на сборке (токен бота в этой сессии не задавался)

проба ответ
нет VFLIN_TG_BOT_TOKEN контейнер выходит с 1 и говорит, что делать (@BotFather → токен → cloud/.env) — он не крутится молча
VFLIN_TG_DB_ROLE указывает на несуществующую роль выход с 1: “the database did not confirm a read-only session — is migration 0019 applied?”
настоящая роль, намеренно неверный токен стартует, открывает пул, входит в роль, проходит собственную пробу на запись, затем Telegram отвечает 401 Unauthorized: invalid token specified, и он повторяет каждые 5 s, не падая; сам токен ни в одну строку журнала не попадает
права роли UPDATE, DELETE и INSERT под vflin_ro — это permission denied; SELECT работает; контроль — та же проба на сессии, которая в роль НЕ входила, — сообщает “may write”, то есть отказы принадлежат грантам, а не слепоте пробы
может ли сессия выйти из роли? SET ROLE: даRESET ROLE, затем UPDATE … WHERE false проходит. Логин, который лишь состоит в vflin_ro: нет — та же последовательность даёт permission denied. Обе половины измерены на настоящей базе, ради чего §12.4 и существует
что говорит журнал старта в обеих формах, на настоящем Postgres DSN службы + SET ROLEWARN the panel enters vflin_ro from a login that MAY write: RESET ROLE would restore write rights…; выделенный логин → INFO the panel's database session cannot write even after RESET ROLE — the refusal is the database's. Более сильная фраза никогда не печатается на более слабой настройке
чтение, заблокированное LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE сдаётся по собственному сроку в 10 s и отвечает словами. Красный прогон без этого срока: panic: test timed out after 25s внутри pgconn.receiveMessage — цикл команд, а вместе с ним и тревоги о здоровье, остановился бы на всё время удержания блокировки
Redis недостижим во время /find в ответе написано “лимиты: прочитать не удалось (…) — это НЕ значит, что они никого не держат”; /stats опускает счёт окон, а не печатает ноль, которого не измерял
Telegram отказывает в отправке тревоги сообщение сохраняется и отправляется заново на следующем такте; шестичасовая тишина по диску и отметка «сводка сегодня отправлена» начинаются с ДОСТАВКИ, а не с попытки

13. Когда код при регистрации не доходит — [MEASURED] (S12, 2026-09-12, ISSUE-204)

2026-09-12 владелец зарегистрировался с телефона, и письмо не пришло, а всё на нашей стороне говорило, что оно ушло: register ответил 200 за 783 ms (register ЖДЁТ релей, то есть релей его принял), код был живым в базе, и за четырнадцать часов не появилось ни одной строки журнала — почтовик говорил только при неудаче. Эта тишина и была дефектом, и она наша, даже если потеря не наша: net/smtp читает ответ релея на завершающую точку . и выбрасывает текст, поэтому id очереди, по которому ищут в панели самого провайдера, в нашем процессе никогда не существовал. Назвать письмо кому-либо было нечем.

13.1 Что теперь есть

Каждое принятое сообщение записывает собственный ответ релея в трёх местах:

где строка
журнал, уровень INFO code mail accepted by the relay purpose=verify to=<address key> relay="250 OK: message queued. Message-ID: …"
строка самого кода email_codes.relay_response + relayed_at, подметается вместе с кодом
панель, /find письмо: релей принял как «250 OK: message queued. Message-ID: …», в 14:30 12.09

Адрес целиком не пишется никогда (только его ключ, как и везде — ISSUE-138), а код не пишется вовсе; расписка про конверт, поэтому её безопасно держать в журнале и именно её правильно предъявлять провайдеру. Отсутствие расписки сказано вслухписьмо: ответ релея не записан — это НЕ значит, что письмо ушло — потому что пустое место, которое читается как «всё в порядке», и сделало эту историю возможной.

13.2 Таблица диагноза — измерено, а не предположено

проба результат
одно живое сообщение через релей сборки на …@vflin-antik.com релей ВЗЯЛ его: 250 OK: message queued. Message-ID: tl9d3d-09croo-6m, за 645 ms
SPF отправляющего домена естьv=spf1 include:_spf.mx.cloudflare.net include:mxsspf.sendpulse.com ~all
DKIM по двенадцати частым селекторам (sendpulse, default, sp, mail, dkim, s1, s2, smtp, selector1, sp1, spf1, k1) на этих двенадцати не найден — и в списке не было того, который имеет значение. ИСПРАВЛЕНО координацией 2026-09-12, измерено против : sign._domainkey.vflin-antik.com (собственный селектор SendPulse, добавленный владельцем 2026-09-10) опубликован, v=DKIM1; k=rsa, и cf2024-1._domainkey (Cloudflare) тоже. То есть ключ DKIM у домена ЕСТЬ. Чего опубликованный ключ всё равно не доказывает — что именно это сообщение несло ВЫРОВНЕННУЮ подпись: это видно только по заголовкам доставленного письма. Строку ниже читайте с этой поправкой.
DMARC (_dmarc.vflin-antik.com) однозначно отсутствуетNXDOMAIN
MX Cloudflare Email Routing (только приём; в пути упавшего теста на Gmail его не было)

Значит, потеря ниже по течению от нашего релея, и у неё есть названная форма. Релей принимает заметно быстрее секунды и называет сообщение. Что крупный получатель делает с письмом, чей домен From публикует SPF, но не несёт вообще никакой политики DMARC (DKIM опубликован, см. исправленную строку выше — невыровненная или отсутствующая подпись на самом сообщении возможна, но не измерена), известно: кладёт в спам или молча роняет. Gmail требует аутентификации каждого отправителя с 2024 года, и молчаливое отбрасывание — задокументированное поведение для неаутентифицированной почты, что и есть наш симптом: принято здесь, не увидено там.

13.3 Что владелец делает, по порядку

  1. Найти у провайдера по id. SendPulse → SMTP → History / Logs → найти tl9d3d-09croo-6m (и для упавшей регистрации — отправку в 14:30 UTC 2026-09-12 на адрес Gmail). Столбец состояния скажет delivered, soft bounce, hard bounce или spam — это одно слово решает всё, что ниже. У каждого письма с кодом отныне есть такой id в нашем журнале и в /find, так что этот шаг доступен всегда.
  2. Опубликовать DMARC для vflin-antik.com — DKIM уже есть (координация, 2026-09-12). Шаг проверки домена в SendPulse ниже стоит делать только чтобы убедиться, что адрес отправителя подтверждён и подпись действительно включена; запись DNS, которую он напечатает, почти наверняка та самая sign, что уже существует. SendPulse → Settings → Sender domains → подтвердить домен; он напечатает точную запись DKIM для Cloudflare DNS. Затем добавить DMARC: _dmarc.vflin-antik.com TXT "v=DMARC1; p=none; rua=mailto:<an address you read>" (адрес, который вы читаете) — p=none сам по себе ничего в доставке не меняет; он делает домен аутентифицированным и наблюдаемым, а отчёты говорят, кто отправляет от вашего имени. Цена: ничего, две записи DNS.
  3. Проверить, что адрес From — ПОДТВЕРЖДЁННЫЙ отправитель в SendPulse. Неподтверждённого отправителя принимают по SMTP и роняют после — та же молчаливая форма.
  4. И только потом думать про второй релей. Не раньше: смена провайдера, пока домен не аутентифицирован, переносит ту же проблему на новый счёт.

13.4 Если релей начнёт ОТКАЗЫВАТЬ — альтернатива и её цена

Здесь он не отказывал; это на случай, и числа — прайс на момент написания, который надо подтвердить, прежде чем что-то тратить:

вариант цена что покупает
остаться на SendPulse и починить аутентификацию (§13.3) $0 измеренная проблема — аутентификация, а не релей
второй релей в VFLIN_SMTP_FALLBACK_ADDRне построен; это была бы небольшая правка здесь переживает отказ одного провайдера, удваивает число аккаунтов, которые надо вести
Amazon SES $0.10 за 1 000 сообщений самый дешёвый на объёме; требует той же работы с DKIM/DMARC плюс выхода из песочницы
Postmark от ≈ $15/month (10 000) сильнейшая доставляемость транзакционной почты из трёх; самый дорогой за сообщение на наших объёмах
Resend / Mailgun бесплатный порог ≈ 3 000/month, дальше ≈ $15–20 сопоставимо с SendPulse

Провайдер в этой сессии не меняется. Это деньги владельца и решение владельца, а измерение говорит, что провайдер свою работу делает: он принял за 645 ms и назвал имя сообщения.