Как поднять облако на арендованной машине примерно за час и чего это стоит. Написано 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 login → keyring import --password <passphrase> → session unlock →
cloud 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 setup → cloud register → profile create/start/stop → cloud 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 down → up -d --wait |
замерено, четыре раза на этой неделе | 19–62 s, здоровье ok |
| Docker Desktop стартует вместе с Windows | %APPDATA%\Docker\settings-store.json → AutoStart |
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.com → 127.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/health → 200 с Access-Control-Allow-Origin: https://vflin-antik.com и Vary: Origin; предзапрос OPTIONS /v1/auth/login-init → 204 с разрешёнными методами; чужой Origin → 200 без разрешающего заголовка (браузер оставляет ответ на той странице). 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-url → GET через публичное имя |
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 ROLE → WARN 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 Что владелец делает, по порядку
- Найти у провайдера по id. SendPulse → SMTP → History / Logs → найти
tl9d3d-09croo-6m(и для упавшей регистрации — отправку в 14:30 UTC 2026-09-12 на адрес Gmail). Столбец состояния скажетdelivered,soft bounce,hard bounceилиspam— это одно слово решает всё, что ниже. У каждого письма с кодом отныне есть такой id в нашем журнале и в/find, так что этот шаг доступен всегда. - Опубликовать 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. - Проверить, что адрес From — ПОДТВЕРЖДЁННЫЙ отправитель в SendPulse. Неподтверждённого отправителя принимают по SMTP и роняют после — та же молчаливая форма.
- И только потом думать про второй релей. Не раньше: смена провайдера, пока домен не аутентифицирован, переносит ту же проблему на новый счёт.
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 и назвал имя сообщения.