VFLIN

docs · docs/50-cloud/DEPLOY.md · перевод от 2026-11-07 · с английской ревизии bbe2bb16e03d

Свой сервер

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 (внутреннее: запись нашего прода в docs/50-cloud/DEPLOY.md, не публикуется)) читает сначала 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 (внутреннее: запись нашего прода в docs/50-cloud/DEPLOY.md, не публикуется))

Сделайте резервную копию 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 && VFLIN_COMMIT=$(git rev-parse HEAD) docker compose -f cloud/docker-compose.yml up -d --build --wait

VFLIN_COMMIT (с S69) — коммит, из которого, по словам образа, он собран: бинарник печатает его в первой строке журнала (vflin-cloud listening … "commit"), а образ несёт его как org.opencontainers.image.revision. Без него старт предупреждает и говорит unknown. До него узнать, что работает на проде, можно было только одним способом: пересобрать коммит-кандидат и сравнить бинарник байт в байт (§18.28 (внутреннее: запись нашего прода в docs/50-cloud/DEPLOY.md, не публикуется)).

Миграции применяются при старте (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 минут); а с S67 мёртв и каждый неиспользованный код восстановления двухэтапного входа — поэтому старт ОТКАЗЫВАЕТ новому секрету, пока такие коды есть, и печатает, сколько их (§18.27 (внутреннее: запись нашего прода в docs/50-cloud/DEPLOY.md, не публикуется), доработка 2) верните старый секрет, если он есть. Если нет: породите новый и запустите ОДИН раз с VFLIN_RECOVERY_CODES_VOID=<the value the refusal printed>, потом уберите её; люди с включённым двухэтапным входом выпустят новый набор в кабинете своим аутентификатором. Тот, кто в середине регистрации, попросит свежий код (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), и ничего из этого не запускалось.

Две вещи, которых нет в шагах выше и которые стоили нам по нескольку дней каждая. Они общие, а не наши — поэтому они здесь, а не в операторских разделах, где живёт остальная наша кухня.

  1. CDN или обратный прокси перед хранилищем объектов ограничивает одну загрузку. За Cloudflare один PUT — не больше 100 MiB, а профиль бывает больше, поэтому облако режет его на части. Если вы ставите что-то перед своим бакетом, найдите этот предел раньше, чем в него упрётся человек: сбой приходит как отказ в загрузке профиля, который вчера был в порядке, а сегодня просто стал больше.
  2. 250 OK от почтового релея не значит, что письмо дошло. Релей его принял; сторона получателя всё ещё может молча его выбросить, и Gmail делает ровно это, когда у домена отправителя нет записи DMARC. Коды регистрации тогда исчезают без единой ошибки в ваших журналах. Настройте SPF и DMARC для домена отправителя, прежде чем доверять регистрации, и проверяйте на настоящем ящике у крупного провайдера, а не только на своём.