С ЧЕГО НАЧАТЬ
Начни отсюда
Redis
Telegram Bot API
Rust
Работают три процесса и два хранилища. Служба доставки упорядочивает сообщения и раздаёт их; комнаты и их журналы она держит в Postgres, а присутствие, ведёрки лимитов и живые каналы делит через Redis — поэтому больше одного её экземпляра это решение о развёртывании, а не переписывание. Рядом шлюз Bot API говорит телеграмной поверхностью с теми ботами, что ты запустишь.
Шифрование — MLS, RFC 9420: стандарт, а не наша выдумка, с прямой секретностью и восстановлением после компрометации, и именно оно делает так, что групповой разговор стоит одного сообщения, а не одного на участника. Служба никогда не держит ключа, который что-то открывает: она упорядочивает шифротекст, и порядок — весь её вклад. И она, и шлюз написаны на Rust; приложение — Flutter, одна кодовая база на телефоны, десктопы и веб.
Развёртывание — это по контейнеру на каждого плюс Postgres и Redis, на которые их направить; compose-файл лежит в репозитории и сам собирает образ вместе с миграциями, которые служба прогоняет по своей базе при старте.
При первом запуске шлюз чеканит @botfather и печатает его личность в журнал. Из этого аккаунта берутся боты, и это обычный бот на той же поверхности, что и твои: он шлёт сообщения, вешает клавиатуры, отвечает на нажатия и правит свои сообщения тем же кодом. Что сломано в кнопках — сломано и у него, ради этого он так и сделан.
ТОЛЬКО ЛОКАЛЬНО
Шлюз отдаёт открытый текст и слушает 127.0.0.1; TLS здесь нет. В URL больше нет ничего секретного — запрос доказывает себя подписью, а не тем, что несёт в пути, — но открытый текст всё равно показывает каждое отправленное и полученное ботом сообщение любому, кто на проводе. Вынести его на публичный интерфейс — решение о развёртывании с разбором безопасности в придачу, а не флажок, который переключают.
БОТЫ
Боты
Telegram Bot API
Python
Node.js
Rust
MLS
Сильное прочтение слов «совместим с Telegram» — единственное, которое можно проверить: бот, написанный на python-telegram-bot, aiogram, telegraf или grammY, работает с Compax, если поменять базовый адрес и больше ничего. Шлюз сделан именно этим — не похожим на телеграмные кнопки, а тем протоколом на проводе, на котором эти библиотеки уже говорят.
Change the base URL. That is the move — the surface on this side is Telegram’s, to the letter, because the test of “compatible” that means anything is that somebody else’s library runs against it unmodified. Two things are ours, and both are marked: the token in the URL becomes a signature on every request, and a group here can be handed things a chat could not hold.
Бот — участник группы
В Telegram бот — это аккаунт на серверах Telegram, а Bot API — вид на чужую базу данных. Здесь сервер держит шифротекст и счётчик эпох; открытого текста нет нигде, кроме устройства участника. Поэтому у бота своё устройство MLS и свой ключ личности, и в группу он входит участником. Другого места, где для него существовал бы открытый текст, просто нет.
Три следствия, и все они видны в приложении, а не спрятаны им:
- Добавить бота — значит добавить участника. Он появляется в списке участников, потому что составом владеет MLS, и приложение спрашивает, а не держит свою копию.
- Бот читает каждое сообщение в группе, всегда. В Telegram есть режим приватности; мы его реализовать не можем, потому что бот держит ключи группы и расшифровал сообщение раньше, чем любое правило могло бы сработать.
getMeсообщаетcan_read_all_group_messages: true, и это факт об архитектуре, а не настройка. - Удаление бота — настоящее удаление в MLS, которое прокручивает ключи вперёд. «Выгнан, но ключи при нём» — не то состояние, которое здесь выдают за удалённое.
It holds a leaf in the tree, an identity key and a device of its own. The line leaving the circle is the only unusual thing about it.
Как сделать бота
Найди @botfather в поиске по чатам и скажи /newbot. Он спросит имя, имя пользователя, оканчивающееся на bot — по тому же правилу, что и у Telegram, так что код бота с username.endswith("bot") прав, — а затем ключ бота, который твоё приложение выводит из твоей же фразы восстановления. Шлюз этой фразы никогда не видит и сам ключ сделать не может. Он поднимает устройство бота и говорит тебе, чем бот является:
@botfather is an ordinary bot on the same surface as the one it is making — it sends messages and reads replies, and nothing else. The key at the end is the bot's public half; the private one was derived on your device from your own phrase, and the gateway never saw either.
@orders_bot is 9f2c4d19b7e05a3c9d61f8a24b7e0c93d15641ac8e37b2f0a94c6d1e5b83702f. └─ the bot's public key — thirty-two bytes, hex
Это публичный ключ, а не секрет. Он называет бота в адресе и сам по себе ничего не доказывает, так что вставленная куда-то эта строка, лог прокси или история браузера никому ничего не дают. Доказывает запрос подпись, сделанная устройством, которое твоё приложение выдало бэкенду, — см. как направить бэкенд.
Регистрация — то же правило на шаг раньше. Устройство, которое присылает твоё приложение, несёт аттестацию: ключ личности бота подписывает это устройство может говорить за меня. @botfather проверяет эту подпись до того, как что-либо зарегистрирует, поэтому заявить ключ, которым ты не владеешь, не выйдет здесь, а не позже: пришлось бы подделать подпись той личности, которой ты назвался. Собственный ключ устройства в записи тоже сверяется с аттестованным — именно это и делает две половины одним устройством, а не двумя.
Никакого токена здесь нет, и ничего, что бы его заменяло. Форма, которую это вытеснило, была телеграмной — 146492815:AAHq…, идентификатор и тридцать пять символов, — и это был пароль в пути URL: кто им владел, мог говорить от имени бота в каждой группе, где бот состоял, и путешествовал он всюду, куда путешествовала конфигурация бэкенда. Подпись нельзя воспроизвести, нельзя вычитать из пути и можно отозвать.
- /newbot — в приложении @botfather отказывается делать это в группе и говорит почему. Ничего секретного он больше не печатает, но создание бота всё равно поступок с владельцем, а журнал группы — плохое место, чтобы решать, кто это.
- Ключ делает твоё приложение, а не шлюз Бот выводится из твоей фразы восстановления по случайному индексу, так что шлюз держит публичный ключ и число — ни то ни другое бота не воспроизводит. Раньше он генерировал фразу на каждого бота и хранил её открытым текстом: кто прочитал тот файл, тот и был каждым ботом в нём, а потерять файл значило потерять их насовсем. Теперь фраза, которая у тебя и так есть, возвращает любого.
- Добавь бота в переписку Создать бота не значит куда-то его добавить. Вход в группу — это commit в MLS, а сделать его может только участник, — поэтому добавляет участник, из списка участников группы. Само себя ничто не впускает.
- Выдай устройство и запускай бэкенд с ним Ниже. Твоё приложение экспортирует устройство для бэкенда; личность бота остаётся в приложении — именно поэтому утёкший ключ бэкенда это отзыв, а не похороны.
/mybotsпрекращает обслуживание на этом шлюзе с данным устройством, и приложение выдаёт другое на поднятой версии: бот остаётся тем же участником тех же чатов./deleteснимает бота с этого шлюза — его запись, его устройство, его имя, — а его лист остаётся в каждой группе, где он был, молча, пока кто-нибудь не удалит его там. Чего/deleteбольше не делает — так это не прекращает бота: он выведен из твоей фразы, поэтому тот же ключ возвращается по тому же индексу. Раньше эта дверь была в одну сторону, потому что единственная копия личности бота лежала на диске шлюза.
Собственный реестр шлюза перечисляет каждого известного ему бота: публичный ключ и устройство, с которым его обслуживают. И то и другое публично, поэтому прочитать его — это проверка состояния, а не способ до чего-то добраться; и это новое. Раньше в том файле лежали рабочие учётные данные каждого бота на машине и мнемоника, воспроизводящая каждого из них.
The gateway holds a public key and a number, neither of which reproduces the bot. Deleting one is no longer a one-way door.
Как направить сюда бэкенд
Каждый метод живёт по адресу http://127.0.0.1:8081/bot<key>/<method>, где <key> — публичный ключ бота, а не учётные данные. Работают и GET, и POST; параметры приходят как JSON, как форма или в строке запроса, а ответ — привычный конверт {"ok": true, "result": …}.
Что запрос обязан нести дополнительно — это подпись: заголовок x-chat-relay, покрывающий метод, путь вместе с запросом, тело, отметку времени и одноразовое число, сделанный тем ключом устройства, который экспортировало твоё приложение. Это тот же конверт, которым серверы этого развёртывания говорят между собой, — так что правильно сделать надо одну конструкцию, а не две.
КЛЮЧ В ПУТИ — НЕ ТО, ЧЕМ ВХОДЯТ
Назвать публичный ключ не значит аутентифицировать кого-либо: это адрес, и записать его может кто угодно. Входят тем, что владеют приватным ключом, а единственное, что это показывает, — подпись.
Поэтому шлюз не верит тому ключу, который называет конверт. Он смотрит, каким устройством он обслуживает этого бота, отклоняет запрос, если конверт называет любой другой ключ, и только потом сверяет подпись с ним. Назвать чужого бота и подписать своим ключом — значит получить совершенно верную подпись и 404.
Всё, что покрывает подпись, сверяется с тем, что действительно пришло: подпись над getMe не разрешает sendMessage, запечатанная для другого развёртывания отвергается этим, старше или новее минуты — отвергается, а одноразовое число тратится один раз, так что перехваченный запрос нельзя отправить дважды.
Тогда зачем ключ в пути вообще? Для аутентификации он избыточен, и об этом стоит сказать, а не оставлять на догадку: конверт называет устройство, так что шлюз мог бы найти бота по одной подписи. Этот сегмент там потому, что форма адреса — телеграмная: каждая библиотека собирает /bot<something>/<method> из своего аргумента-токена, и что-то туда положить надо. А раз надо, выбор между секретом и именем, и логировать можно именно имя. Есть и третья польза: запрос обязан заявить, для какого он бота, и подпись сверяется с этим — так что адресоваться одному боту, подписавшись другим, значит получить отказ, а не тихое обслуживание от имени подписавшего.
Это единственное место, где телеграмной библиотеке нужна помощь: путь и тело она соберёт верно, а подписывать ничего не станет. Несколько строк middleware в том HTTP-клиенте, которым она пользуется, — вся адаптация; взамен ничто из того, чем владеет твой бот, нельзя вычитать из адреса, лога прокси или вставленного куда-то текста.
Две вещи, и секрет из них только одна
Бэкенду дают один файл — тот, что экспортировало твоё приложение, — и всё берётся из него:
bot— публичный ключ. Он идёт в путь, и это всё, что он делает. Им никогда ничего не подписывают: публичный ключ подписывать не умеет, и поэтому его безопасно класть в URL.record.signing_seed— приватный ключ устройства, тридцать два байта. Им подписывается каждый запрос, и он никогда не покидает бэкенд. Это и есть секрет, и файл, который его держит, — то, что надо беречь.record.attestation— ключ личности бота, говорящий это устройство может говорить за меня. Именно это шлюз проверил при установке устройства.
{
"bot": "9f2c4d19…", // public — this goes in the path
"base_url": "http://127.0.0.1:8081",
"record": {
"device_id": "4b1f…",
"signing_seed": "a3f0…", // PRIVATE — this signs every request
"attestation": { …the identity's signature over this device… }
}
}
# Ed25519: the seed is the private key, and the key in the path is its
# public half. The gateway holds only the public half and checks the
# signature against it — so possession of the private key is the proof,
# and it is the one thing that never travels.Как подписать запрос, целиком
Двадцать строк и никаких зависимостей. Это вся авторизация: разложи байты, подпиши их, положи результат в заголовок.
The order is the format. The three lengths are the point — a four-byte count fused to the front of each field whose size the format does not fix.
Зачем три префикса длины. Без них длинный путь с коротким телом раскладывается ровно так же, как короткий путь с длинным, — и один подписанный запрос можно прочитать как другой, причём подпись сойдётся. Длина перед каждым переменным полем — то, что делает склейку читаемой ровно одним способом.
- Тело — это ровно те байты, которые ты отправишь. Не их хеш и не пересериализация: библиотека JSON, переставившая ключ или сменившая пробел, даёт подпись над чем-то другим, а не над тем, что придёт.
- Одноразовое число — то, что останавливает повтор. Подпись доказывает, кто написал запрос, и ничего о том, когда; число одноразово, а
issued_atсверяется с окном расхождения в минуту — так что одни и те же подписанные байты нельзя предъявить дважды. - Литерал подписывается первым, намеренно. Подпись, сделанную для этого конверта, нельзя воспроизвести как подпись для другого протокола, который случайно раскладывает поля так же.
import { createPrivateKey, sign, randomBytes } from "node:crypto"; // An Ed25519 seed is a raw 32 bytes; node wants PKCS#8, which is that // seed behind a sixteen-byte header that never changes. const PKCS8 = Buffer.from("302e020100300506032b657004220420", "hex"); const keyFromSeed = (seed) => createPrivateKey({ key: Buffer.concat([PKCS8, seed]), format: "der", type: "pkcs8" }); const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; }; const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; }; function relayHeader({ seed, publicKey, home, method, path, body }) { const issuedAt = Math.floor(Date.now() / 1000); const nonce = randomBytes(16); const signed = Buffer.concat([ Buffer.from("chat-relay-envelope-v1"), publicKey, Buffer.from(home, "hex"), u32(Buffer.byteLength(method)), Buffer.from(method), u32(Buffer.byteLength(path)), Buffer.from(path), u32(body.length), body, u64(issuedAt), nonce, ]); const signature = sign(null, signed, keyFromSeed(seed)); // the private key return Buffer.from(JSON.stringify({ device_pubkey: [...publicKey], // an array of numbers, not hex home, method, path, issued_at: issuedAt, nonce: [...nonce], // likewise signature: signature.toString("hex"), // this one is hex })).toString("base64url"); // no padding }
import base64, json, os, struct, time from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey def relay_header(seed, public_key, home, method, path, body): issued_at, nonce = int(time.time()), os.urandom(16) signed = (b"chat-relay-envelope-v1" + public_key + bytes.fromhex(home) + struct.pack(">I", len(method)) + method + struct.pack(">I", len(path)) + path + struct.pack(">I", len(body)) + body + struct.pack(">Q", issued_at) + nonce) signature = Ed25519PrivateKey.from_private_bytes(seed).sign(signed) envelope = json.dumps({ "device_pubkey": list(public_key), "home": home, "method": method.decode(), "path": path.decode(), "issued_at": issued_at, "nonce": list(nonce), "signature": signature.hex(), }).encode() return base64.urlsafe_b64encode(envelope).rstrip(b"=").decode()
И применяется: тело формируется один раз, а затем и подписывается, и отправляется. Сформировать его дважды — так и выходит подпись над тем, чего в запросе нет, а следующий за этим отказ называет подпись, а не ту строчку, которая всё испортила.
const export_ = JSON.parse(fs.readFileSync(process.env.BOT_DEVICE, "utf8")); const seed = Buffer.from(export_.record.signing_seed, "hex"); const publicKey = createPublicKey(keyFromSeed(seed)) .export({ type: "spki", format: "der" }).subarray(12); const home = export_.record.attestation.attestation.home; const path = `/bot${export_.bot}/sendMessage`; const body = Buffer.from(JSON.stringify({ chat_id: -1001, text: "Hello." })); await fetch(export_.base_url + path, { method: "POST", headers: { "x-chat-relay": relayHeader({ seed, publicKey, home, method: "POST", path, body }), "content-type": "application/json", }, body, });
export = json.load(open(os.environ["BOT_DEVICE"])) seed = bytes.fromhex(export["record"]["signing_seed"]) public_key = Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw) home = export["record"]["attestation"]["attestation"]["home"] path = f"/bot{export['bot']}/sendMessage" body = json.dumps({"chat_id": -1001, "text": "Hello."}).encode() requests.post( export["base_url"] + path, headers={ "x-chat-relay": relay_header(seed, public_key, home, "POST", path, body), "content-type": "application/json", }, data=body, # the same bytes that were signed )
from telegram.ext import ApplicationBuilder, CommandHandler from nacl.signing import SigningKey # any Ed25519 will do export = json.load(open(os.environ["BOT_DEVICE"])) device = SigningKey(bytes.fromhex(export["record"]["signing_seed"])) # the private key app = ( ApplicationBuilder() .token(export["bot"]) # the PUBLIC key — it only # builds the path .base_url("http://127.0.0.1:8081/bot") .request(SigningRequest(device)) # the private key signs here .build() ) async def start(update, ctx): await update.message.reply_text("Hello from Compax.") app.add_handler(CommandHandler("start", start)) app.run_polling()
from aiogram import Bot, Dispatcher from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TelegramAPIServer session = AiohttpSession( api=TelegramAPIServer.from_base("http://127.0.0.1:8081") ) # export["bot"] is public and addresses the bot; the seal below is what # proves anything, and it is made with export["record"]["signing_seed"]. bot = Bot(token=export["bot"], session=session) # session.middleware(…) is where the x-chat-relay header goes on dp = Dispatcher()
// The public key goes where the token went — it addresses, it does not // authenticate. signing() below holds record.signing_seed and seals each // request with it. const bot = new Bot(export.bot, { client: { apiRoot: "http://127.0.0.1:8081", fetch: signing(device), // adds x-chat-relay to each request }, }); // telegraf const bot = new Telegraf(export.bot, { telegram: { apiRoot: "http://127.0.0.1:8081", agent: signing(device) }, });
BASE=http://127.0.0.1:8081/bot$BOT_KEY # Every request needs an Ed25519 signature over the method, the path, # the body, a timestamp and a nonce — so curl on its own cannot make # one, and an unsigned request is answered 404 like any other stranger. # $SIGN below stands for whatever produces that header for you. curl -s -X POST $BASE/sendMessage \ -H "x-chat-relay: $(SIGN POST /bot$BOT_KEY/sendMessage "$BODY")" \ -H 'content-type: application/json' \ -d "$BODY" # the header is base64url of this, signed with the exported device key: # {"device_pubkey":…,"home":…,"method":"POST","path":"/bot…/sendMessage", # "issued_at":1731000000,"nonce":…,"signature":…}
// crates/botapi/examples/demo_bot.rs — reqwest and serde_json // against a base URL, plus the one helper that signs. // What your application exported: which bot this is, where it lives, // and the device that speaks for it. No identity key is in the file. let export: signing::Export = serde_json::from_slice(&std::fs::read(std::env::var("BOT_DEVICE")?)?)?; // Rendered once, then both signed and sent. Rendering twice is how a // signature ends up covering something the request does not contain. let path = format!("/bot{}/{method}", hex::encode(export.bot)); let body = serde_json::to_vec(¶ms)?; let signed = signing::header(&device, home, "POST", &path, &body); let answered: Value = http .post(format!("{base}{path}")) .header(protocol::relay::RELAY_HEADER, signed) .header(CONTENT_TYPE, "application/json") .body(body) .send().await?.json().await?;
ПРОВЕРКА, КОТОРУЮ СТОИТ СДЕЛАТЬ ОДИН РАЗ
Направь тот же двоичный файл на https://api.telegram.org с настоящим телеграмным токеном и убери подписывающий заголовок. Если методы, параметры и ответы перестанут совпадать — заявление о совместимости на этой странице неверно, и это единственная проверка, способная его опровергнуть. Демонстрационный бот написан так, чтобы его можно было запустить обоими способами, ровно за этим.
Чего стоит подпись, стоит сказать прямо: формы телеграмные, но библиотеку больше нельзя направить сюда правкой одной строки. Ей нужно несколько строк middleware в том HTTP-клиенте, которым она пользуется, и сегодня единственный разобранный пример такого middleware — на Rust.
Чего не заявляется пока: набора проверок соответствия, порождённого из схемы Bot API, и примеров ботов на библиотеках, гоняемых в CI. И то и другое записано как работа, а не как сделанное. Заявление на сегодня — «методы в таблице ниже, проверенные», а не номер версии.
The Bot API, unchanged, plus one header. What is missing is the token: nothing in the path is a secret, and the signature is what gets you in.
Цикл обновлений
Опрашивай через getUpdates. Обновление подтверждается полем offset следующего вызова, как это делает Telegram: limit по умолчанию 100 и там же обрезается, timeout — длинный опрос до 300 секунд, а allowed_updates фильтрует.
{
"update_id": 41,
"message": {
"message_id": 17, // the group log's own seq
"date": 1755993600,
"chat": { "id": -1001, "type": "group", "title": "Ridge Trip" },
"from": { "id": 5501, "is_bot": false, "first_name": "Ada" },
"text": "/ask something",
"entities": [{ "type": "bot_command", "offset": 0, "length": 4 }]
}
}Эти entities — не украшение. CommandHandler в python-telegram-bot и CommandStart в aiogram вообще не смотрят на текст: они находят сущность bot_command со смещением 0 и берут команду из этого промежутка. Шлюз считает их в кодовых единицах UTF-16, потому что именно этой единицей режет каждая библиотека, и одно эмодзи перед командой сдвигает всё после него на два.
Идентификаторы
Каждая библиотека Bot API типизирует chat.id и from.id как 64-битные целые, а не как непрозрачные строки, и настоящий код ботов ветвится по знаку. Наши идентификаторы — 32-байтовая личность в шестнадцатеричном виде и UUID, поэтому шлюз держит постоянный реестр, который чеканит их один раз и никогда не переиспользует:
| Поле Bot API | Наша сущность | Правило |
|---|---|---|
| chat.id | идентификатор группы (UUID) | отчеканенный i64, отрицательный для групп и положительный для чата один на один |
| from.id | личность в шестнадцатеричном виде | отчеканенный положительный i64, устойчивый навсегда |
| message_id | seq в журнале группы | напрямую — уже целое, уже своё для чата, уже согласовано каждым участником |
| update_id | счётчик шлюза | монотонный для каждого бота |
Клавиатуры и обратные вызовы
Встроенная клавиатура — это собственный JSON Telegram, пропускаемый как есть, а не пересобранный по своей модели: круговой ход, теряющий поле, — это ошибка соответствия, которую мы хотим видеть тестом, а вторая схема прячет такие по построению. Повесь её на сообщение — и нажатие вернётся как callback_query.
WHAT THE SERVER SEES
- POST
/sendMessage{"text": "Flat white or latte?", "reply_markup": {"inline_keyboard": [[{"text": "Latte", "callback_data": "cup:latte"}]]}} - ←
callback_query{"id": "cb-7", "data": "cup:latte", "from": {"id": 5501}}The tap. Nothing has changed on the screen yet. - POST
/answerCallbackQuery{"callback_query_id": "cb-7"}This is what stops the spinner — nothing else does. - POST
/editMessageText{"message_id": 18, "text": "Latte — ready in about four minutes."}No reply_markup, so the keyboard goes.
Watch what does not happen. The key goes down and stays down — nothing on this screen knows what the answer is, because the answer is the bot's. The spinner stops when answerCallbackQuery lands, and the message is redrawn in place: same bubble, new words, no keyboard. A second press is not refused, it is unavailable.
POST /bot$KEY/sendMessage { "chat_id": -1001, "text": "Flat white or latte?", "reply_markup": { "inline_keyboard": [[ { "text": "Flat white", "callback_data": "cup:flat" }, { "text": "Latte", "callback_data": "cup:latte" } ]] } }
Кто-то нажимает. На его экране не меняется ничего: приложение отправило строку и ждёт, и весь этот обмен именно об этом. То, что получает бот, — не тот запрос, который он делал:
{ "update_id": 42, "callback_query": {
"id": "cb-7", "data": "cup:latte",
"from": { "id": 5501, "first_name": "Ada" },
"message": { "message_id": 18, "chat": { "id": -1001 } } } }Отвечают на это два вызова. Первый останавливает крутилку, и только он это умеет; второй правит сообщение, уже лежащее в переписке, — так клавиатуру нельзя нажать второй раз.
POST /bot$KEY/answerCallbackQuery { "callback_query_id": "cb-7", "text": "Ordering…", "show_alert": false } POST /bot$KEY/editMessageText { "chat_id": -1001, "message_id": 18, "text": "Latte — ready in about four minutes." }
У InlineKeyboardButton сегодня поддержаны: text, callback_data, url и copy_text. web_app, switch_inline_query* и login_url — более поздние этапы; pay и callback_game отклоняются, см. ниже. Ответные клавиатуры, ForceReply и ReplyKeyboardRemove рисует приложение; превращать нажатие такой клавиатуры или выбранный вариант опроса обратно в обновление шлюз пока не умеет.
ДАННЫЕ ОБРАТНОГО ВЫЗОВА В ГРУППЕ НЕ ПРИВАТНЫ
В Telegram нажатие встроенной кнопки отправляет callback_data боту и больше никому. Здесь нажатие — управляющее сообщение в журнале группы, зашифрованное для группы, а значит, его видит каждый участник.
Внутри группы это не чинится: бот — участник, а сообщение, которое мог бы прочитать только бот, потребовало бы второго обмена ключами с ним одним. В чате один на один с ботом третьей стороны нет, и семантика совпадает с телеграмной. В группе нажатия видны участникам — ровно то же свойство, что уже есть у опросов. Не клади секрет в кнопку.
Родные виджеты
Клавиатура — это ряды кнопок, поэтому любой вопрос, кроме какая из этих кнопок, превращается в разговор: одно сообщение на значение и никакой возможности собрать на месте набор, количество или день. Виджет — ответ на это: карточка, которую приложение рисует из JSON, присланного ботом, в том же сообщении, с тем же каналом обратного вызова под ним.
Каждый существующий элемент управления, что именно каждый из них возвращает, и конструктор, который пишет JSON и рисует карточку, пока ты крутишь его ручки, вынесены на отдельную страницу. Раньше это было четыреста строк в середине этой — справочный раздел, стоящий там, где место руководству.
API, метод за методом
Ничем не сгенерировано: это прочитано с crates/botapi/src/api.rs, и метод, не названный здесь, отвечает 404.
| Метод | Состояние | Примечания |
|---|---|---|
getMetakes nothinganswers{"ok": true, "result": User}Reports can_read_all_group_messages: true, always — a bot here is a member of the group and decrypts everything in it. | работает | сообщает can_read_all_group_messages: true, всегда |
getUpdatesoffsetthe first update to return; confirming everything before itlimitup to 100, and 100 if you do not saytimeoutseconds to hold the request open, up to 300; 0 returns at onceallowed_updatesan array of the kinds you wantanswers{"ok": true, "result": Update[]}Long polling. Advance offset before you handle, not after, or a handler that panics answers the same message forever. | работает | offset, limit (≤100), timeout (≤300 с), allowed_updates |
sendMessagechat_idrequiredwhich conversationtextrequiredthe message, and the fallback for a client that cannot draw a widgetreply_markupan inline keyboard, Telegram's own JSON, passed through verbatimreply_to_message_idthe message this answerswidgeta native card — see the widget sectionanswers{"ok": true, "result": Message} | работает | text, reply_markup, reply_to_message_id, widget |
editMessageTextchat_idrequiredmessage_idrequiredtextrequiredthe new textreply_markupthe new keyboard; leaving it out takes the buttons awayanswers{"ok": true, "result": Message}Last one wins, and only from the sender of that message. | работает | побеждает последний, и только от отправителя того сообщения |
editMessageReplyMarkupchat_idrequiredmessage_idrequiredreply_markupno markup removes the buttons; an empty keyboard is a different thinganswers{"ok": true, "result": Message} | работает | отсутствие разметки убирает кнопки; пустая клавиатура — это другое |
editMessageWidgetchat_idrequiredmessage_idrequiredwidgetthe next state of the card; no widget restores the textanswers{"ok": true, "result": true}Ours, not Telegram’s. This is how a card advances without sending a second message. | работает | наше, не телеграмное; без widget восстанавливается текст |
answerCallbackQuerycallback_query_idrequiredthe id from the updatetextthe toast, at most 200 characters — longer is refused, not truncatedshow_alerta dialog instead of a toast, for what must not be missedurlhttps only, at most 2048 characters; anything else is refusedanswers{"ok": true, "result": true}The control is drawn waiting until this lands, so answer first and redraw after. | работает | text, show_alert, url; отвечает true, а не объектом |
getChatchat_idrequiredanswers{"ok": true, "result": Chat} | работает | type — это private или group, никогда supergroup |
setMyCommandscommandsrequiredthe listscopedefault, all private chats, a chat, a memberlanguage_codethe language this list is foranswers{"ok": true, "result": true}Refused with a reason when there are too many commands, or too many scopes and languages of them.getMyCommands scopelanguage_codeanswers{"ok": true, "result": BotCommand[]}deleteMyCommands scopelanguage_codeanswers{"ok": true, "result": true} | работает | все семь областей, с телеграмным разрешением «сначала самая частная» |
setChatMenuButtonchat_idomit for the defaultmenu_buttonanswers{"ok": true, "result": true}getChatMenuButton chat_idomit for the defaultanswers{"ok": true, "result": MenuButton} | работает | сохраняется и отдаётся обратно |
| setMyName / Description setMyShortDescription short_descriptionlanguage_codeanswers{"ok": true, "result": true}…and their getters | работает | вызывается при старте обеими библиотеками на Python, так что 404 здесь остановил бы бота до первого сообщения |
deleteWebhooktakes nothinganswers{"ok": true, "result": true}There is no webhook. Answered so a library’s first call does not fail.getWebhookInfotakes nothinganswers {"ok": true, "result": an object saying no webhook is set} | отвечено | вебхука и правда нет, а почти каждая библиотека спрашивает до первого опроса |
setWebhooktakes nothinganswers{"ok": true, "result": 400}Not built yet — poll with getUpdates. | 400 | пока не сделано — опрашивай через getUpdates |
| sendInvoice answerPreCheckoutQuery createInvoiceLink | отклонено | Telegram Payments предполагает, что средства держит Telegram. У нас есть счета в сети и групповые кошельки с мультиподписью — вещь лучшая и другая |
| sendGame setGameScore getGameHighScores | отклонено | игровой платформы нет, а притворяться значит сломаться хуже, чем от честной ошибки |
| banChatMember restrictChatMember promoteChatMember …и остальные | отклонено | группы MLS плоские: выдавать нечего, модели администрирования нет |
| Файлы: sendPhoto, sendDocument, getFile | запланировано | вложения — это зашифрованные блобы; шлюз будет отдавать расшифрованные байты только на локальной петле, адресуемые ключом бота в пути и подписанные, как всякий другой вызов |
| Inline-режим Web Apps | запланировано | этапы 4 и 5 — у браузера dApp уже есть четыре из пяти частей, нужных хосту Web App |
Где это не Telegram
Четыре вещи здесь не «пока нет». Они стоят на платформе, которой тут не существует, и правильно оформленная ошибка — честный ответ, а не полуправда:
- Платежи. Модель Telegram — это токен провайдера и Telegram, берущий средства на себя. Отобразить
sendInvoiceна наши счета значило бы вручить ботам платёж, который они не смогут сверить. Отклонено, взамен предложено родное расширение. - login_url. Он вручает сайту идентификатор пользователя Telegram, подписанный Telegram. Здесь нет идентификатора пользователя Telegram; личность — это пара ключей, которой браузер dApp уже подписывает входы. Родное отображение строго лучше, а телеграмного просто нет.
- Каналы, супергруппы, права администратора, заявки на вступление. Группы MLS плоские.
chat.type— этоprivateилиgroup, а методы администрирования чата отвечают400. - Стикеры, игры, бизнес-аккаунты, Premium, бусты, подарки. Ни соответствия, ни планов.
И две вещи ослаблены, а не отсутствуют: request_contact возвращает Contact с user_id и именами, но с пустым phone_number, потому что личность здесь — пара ключей, и номеров телефона, которыми можно поделиться, нет; и режим приватности выключен навсегда, по причине из первого раздела.
Прежде чем запускать всерьёз
- Опрашивай из одного процесса. Два опрашивающих на одного бота крадут друг у друга обновления. Шлюз тебя не остановит; симптом — бот, отвечающий на каждое второе сообщение.
- Двигай
offsetдо обработки, а не после, чтобы обработчик, который упал, не оставил бота в цикле вечных ответов на одно и то же сообщение. - Относись к экспортированному устройству как к секрету. Ключ в адресе публичен, и логировать его безопасно; файл, который экспортировало твоё приложение, — нет. Держи его вне образов и переменных CI, а если он всё-таки утёк,
/mybotsего отзывает — ради этого бэкенд и держит устройство, а не бота. - Скажи, зачем бот, когда он входит. Он читает каждое сообщение в группе с момента добавления; приложение это показывает, а бот, который объясняется первым, — разница между участником и неожиданностью.
- Перепроверяй каждое событие на входе. Спецификация, спрятавшая кнопку, — это рисунок, а не запрет.
СВОЙ СЕРВЕР
Подними сервер сам
Docker
PostgreSQL
Redis
Prometheus
Grafana
Всё, что выше, говорит с сервером. Он может быть нашим или твоим, и в боте между этими двумя случаями не меняется ничего: шлюз — один и тот же двоичный файл. Служба доставки, клиентский SDK и форматы на проводе лежат в одном репозитории, askucher/compax‑core, и compose-файл рядом с ними поднимает всё, что нужно: Postgres, Redis, хранилище блобов, два экземпляра и прокси перед ними.
git clone https://github.com/askucher/compax-core cd compax-core/deploy # placeholders that start; each line says why it is not one to deploy cp .env.example .env # the first build compiles the workspace, so it is not quick docker compose up -d --build curl localhost:8080/health
Один образ несёт оба двоичных файла — службу доставки и шлюз Bot API, — собранных из одного рабочего пространства и одного lock-файла, так что схема, код, который её применяет, и то, что с ней говорит, не могут разойтись во мнении, кто из них старше. Миграции идут при запуске: первый up он же и установка.
| Где | Что отвечает |
|---|---|
localhost:8080 | служба, через прокси — апгрейды WebSocket проходят нетронутыми, и живой канал один |
localhost:8083 | шлюз Bot API — на нём висит базовый адрес /bot<key>, где ключ это публичный ключ бота в шестнадцатеричном виде: по нему шлюз находит вызов и по нему же сверяет подпись |
--profile calls | медиасервер и реле, для звонков и конференций |
--profile observability | Prometheus и Grafana, с уже заведённой панелью |
- Два экземпляра по умолчанию. Всё, что несёт корректность, живёт в Postgres, поэтому они взаимозаменяемы, и прокси не нужна привязка сессий. Один экземпляр — тот случай, который работает случайно.
- Прокси говорит открытым текстом, пока ты не дашь ему адрес. Что верно за балансировщиком, который завершает TLS, и неверно как публичный край:
SITE_ADDRESSв.env— это всё, что нужно, чтобы это перевернуть, вместе с сертификатами, а раздел ниже говорит, чего это ещё требует. - Заглушки — это заглушки. Compose отказывается стартовать без пароля Postgres, токена метрик и учётных данных SFU, а
.env.exampleпоставляет значения для всех трёх, чтобы стек поднялся до того, как у тебя появятся настоящие. Разворачивать эти значения нельзя, и файл говорит об этом рядом с каждым.
На другом сервере
Те же команды на машине с Docker, плюс домен. Именно адрес превращает ноутбук в развёртывание: задай его — и прокси сам получит и обновит сертификат этого домена; оставь пустым — и он отдаст обычный HTTP, что верно за балансировщиком с завершением TLS и неверно как публичный край.
ssh you@your-server git clone https://github.com/askucher/compax-core cd compax-core/deploy cp .env.example .env # in .env -- the A record must already point at this host # SITE_ADDRESS=chat.example.com # HTTP_PORT=80 the certificate challenge is answered here # HTTPS_PORT=443 # and real values for the eight the file marks as required docker compose up -d --build docker compose logs -f server-a server-b
Направь клиентов на https://chat.example.com. Сертификат запрашивается при старте прокси, а не по первому запросу, поэтому домен должен разрешаться сюда до первого up; пока это не так, сайт отвечает по обычному HTTP, а испытание повторяется в фоне.
- Два порта, и ещё два только ради звонков. 80 и 443 — это и есть развёртывание: сами экземпляры публикуются на локальной петле, и до них добираются через прокси.
--profile callsдобавляет 7880 и 7881 с 50000–50100/udp для медиасервера и 3478 с 49160–49200/udp для реле. ВыставьTURN_EXTERNAL_IPв публичный адрес машины, когда поднимаешь его: оставленное пустым, реле раздаёт приватный адрес — тот отвечает и никогда ничего не передаёт. - Prometheus и Grafana публикуются на всех интерфейсах, в отличие от всего остального здесь. За брандмауэром или через туннель
ssh -Lони то, чем задумывались; открытые в интернет,--profile observability— это админский вход на порту 3000. - Обновление — это
git pullи тот жеup. Миграции идут при запуске, поэтому второго шага нет и нет окна, в котором схема и код расходятся. - Делай резервные копии тома
dbиbotstate. Первый — это всё, что несёт корректность; второй держит операторскую фразу и ключи каждого бота, и потерять его значит потерять ботов.
Второй сервер и как эти двое видят друг друга
Двум экземплярам одного развёртывания настройка не нужна вовсе: они делят Postgres и Redis, и всё, что что-то решает, лежит в первом из них. Два провайдера — совсем другое дело: отдельные машины, отдельные базы и ничего общего, кроме того, что каждому рассказали про другого.
Друг друга они не обнаруживают. Нет ни реестра, ни сплетен, ни места, где регистрируются: каждой стороне говорят, в её собственном .env, и в этом весь механизм.
| Настройка | Что это |
|---|---|
HOME_LABEL | Шесть шестнадцатеричных цифр, собственные у этого развёртывания. Штампуются в каждый идентификатор группы, который оно чеканит, и по ним пришедший идентификатор говорит, чей он. Изменить их, когда группы уже есть, нельзя: метка внутри идентификатора группы, идентификатор внутри группового контекста MLS, а контекст подписан в каждый commit — так что переименовать дом значит пересоздать его группы. |
PUBLIC_URL | Где другая сторона до этой дотягивается. По умолчанию http://127.0.0.1:$PORT, что верно на ноутбуке и бесполезно всем остальным. |
SERVER_KEY | Тридцать два байта в шестнадцатеричном виде — ключ, которым подписываются запросы этого сервера к узлам. Записывается в файл для экземпляров профилем docker compose --profile federation. Сервер без него не делает запросов к узлам и не публикует ключа — и это сервер, до которого клиенты по-прежнему прекрасно добираются. |
HOME_DIRECTORY | Каждый другой дом, в виде JSON — прямо в строке или путём к файлу. Собственная запись этого сервера оттуда не читается: она всегда заменяется его собственными url и ключом, потому что сервер — власть над своей строкой и ни над чем больше в этом файле. |
MIMI_DOMAIN, MIMI_BLOCKED | Домен, которым это развёртывание отвечает на полосе MIMI, и провайдеры, с которыми оно федерироваться не станет. |
Два дома, выписанные полностью. Каждый файл называет другого и о себе — ничего, кроме собственной метки и url:
HOME_LABEL=a1b2c3 PUBLIC_URL=https://a.example.com SERVER_KEY=1f4c… # openssl rand -hex 32 HOME_DIRECTORY={"homes":{"d4e5f6":{"url":"https://b.example.com","key":"9f2c…"}}}
HOME_LABEL=d4e5f6 PUBLIC_URL=https://b.example.com SERVER_KEY=9f2c… # its own, and the public half is what A holds HOME_DIRECTORY={"homes":{"a1b2c3":{"url":"https://a.example.com","key":"1f4c…"}}}
key в каждой записи — публичная половина SERVER_KEY другой стороны, и именно с ней сверяется запрос узла; поэтому справочник, солгавший о чужом ключе, будет справочником, чьи запросы не проходят проверку подписи, а не тем, чьи проходят. То, что держит каждое развёртывание, публикуется по /.well-known/chat-homes, чтобы другая сторона могла это забрать, а не выслушивать дважды.
Затем подними с профилем, который пишет ключ: docker compose --profile federation up -d. Всё вышеперечисленное по умолчанию пусто, а пусто — это развёртывание, которое одно; и оно всё равно держит каждую группу, о которой его спросят.
Как масштабировать до миллионов
One rule makes the rest of this section short: nothing decisive lives in a process. Sessions are rows, routing is Redis, attachments are wherever both instances agreed to put them — so a second instance is a deployment decision and a twentieth is the same decision again. What follows is the order in which things stop being enough, because that order is not obvious and every one of these steps is wasted if it is taken first.
| Когда | Добавить | Чего это не чинит |
|---|---|---|
| Одна машина занята | Экземпляры за прокси и общий для них Redis | Ничего про базу, которую они теперь делят сильнее |
| Кончаются соединения | PgBouncer в режиме транзакций или больший max_connections | Пропускную способность на запись — те же строки по-прежнему пишутся |
| Нагрузка — это чтения | Реплику, для тех чтений, которым можно отстать на секунду | Журнал, который клиент читает, чтобы догнать, и который отстать не может |
| Нагрузка — это записи | Второй дом, федеративный | Комнату, которая занята сама по себе; см. последний абзац |
| Другой континент | Регион: свои экземпляры, свой Redis | Задержку до дома, который упорядочивает комнату, — это физика |
Бэкенд — лёгкая половина
Every instance is interchangeable and none of them is anybody's home: a client may open its socket on any one, and the fanout finds it through the registry rather than by knowing where it went. Two numbers decide how many you can run.
- Sixteen connections each. That is
max_connections(16)inmain.rs, so ten instances is a hundred and sixty and a stock Postgres accepts a hundred. Past about five instances the answer is a pooler in transaction mode rather than a larger Postgres, because a connection costs memory whether or not it is doing anything.chat_db_pool_connectionsby state is the graph that says which of the two you need. - One socket per device, not per person. A phone, a
desktop and a tab are three, and each one is a file descriptor and a
buffer for as long as it is open.
chat_ws_sockets_openagainst the box's own limit is the ceiling; raisenofilebefore it is reached rather than after.
Attachments have to be shared before the second
instance, not after: BLOB_STORE=s3 with
S3_BUCKET, and S3_ENDPOINT for MinIO or
R2, which speak the same protocol and differ only in that address. A
service that finds a live instance keeping its bytes somewhere else
refuses to start, and that is deliberate: with two disk
directories every download is a coin flip on which instance serves it,
and a 404 for bytes that exist is a bug nobody can reproduce.
Redis — на регион, и никогда между двумя
The registry is written on every connect, and a mobile client reconnects on every change of network — so it is a write-heavy keyspace with a millisecond budget, and a region reaching across an ocean for it turns every reconnection into a round trip. Each region gets its own, and what crosses between regions is the peer lane, which is built for a link that is slow and sometimes down.
База данных, в три хода и именно в этом порядке
First, the partitions you already have. The log is partitioned by month and retention drops a partition rather than deleting rows, so the largest table in the service is bounded by a setting rather than by traffic — see the chart under Storage. Nothing else needs doing until that is true and the graph is flat.
Second, a replica for what may be stale. Less than it sounds: the log is read to catch up, so a replica behind by a second is a client that misses the message it was told about. What moves safely is what nobody is waiting on — the directory reads behind a profile, the public group listings, and the metrics scrapes.
Third, more than one home rather than more than one shard. This is the part where an honest section is worth more
than a complete one: the data is cut along two keys already — rooms and identities — and a room fits
inside one piece whole, but there is one DATABASE_URL
in this code and no router in front of it. What is built is
federation, and it divides the same way: a second deployment with its
own label, its own database and its own Redis, ordering its own rooms
and reaching the first over the peer lane. It is the same cut, made
where the code already supports it.
То единственное, что не делится
A room is ordered by one server and one row: one commit per epoch, a compare-and-swap, and every commit in that room waits behind it. Millions of people in millions of rooms is a flat problem — spread them across homes and every home is busy in parallel. A million people in one room is not, and no amount of hardware changes it, because the serialisation is what MLS requires rather than what this implementation chose. That is the number to design the product around, and it is the reason a broadcast channel and a group conversation are not the same feature.
Хранилище
Хранилища два, и выбирает между ними один вопрос: захочет ли кто-нибудь получить это обратно после перезапуска? Журнал, справочник и входящие — захочет, поэтому они в Postgres. Кто подключён и куда им что проталкивать — не захочет; это Redis, и потерять его целиком стоит одного переподключения. Третьего хранилища нет, и ничего решающего в процессе не кэшируется — ровно поэтому два экземпляра и взаимозаменяемы.
Двадцать одна таблица, шестнадцать индексов и девять внешних ключей — а стрелки говорят ту половину, которую список сказать не может: восемь из девяти приходят в devices, и каждый с on delete cascade. Удаление устройства уносит его сессии, его пакеты ключей, его обёрнутые ключи состояния, его пуш-токен и загруженные им блобы — потому что отзыв, оставивший что-то из этого, был бы отзывом, который ничего не отозвал.
Каждая строка ниже прочитана с migrations/, а что каждая таблица говорит о себе — это её собственный комментарий в схеме: карта, нарисованная по памяти, — это карта, неверная уже на второй миграции. Две, отмеченные акцентом, — те, через которые проходит каждое сообщение.
Две вещи в этой форме стоят внимания. group_log разбит на разделы по месяцам, поэтому сбросить историю значит сбросить раздел, а не удалить сто миллионов строк, — и поэтому же окно есть продуктовое решение (сколько устройство может отсутствовать), а не эксплуатационная ручка. И несколько таблиц намеренно без индекса: sessions подметается четыре раза в сутки сканом, который Postgres выбрал бы и сам, а индекс там пришлось бы поддерживать при каждом входе ради четырёх запросов.
Сколько это будет стоить — сперва форма, и только потом число, и в этой форме есть излом. Каждая здешняя таблица отвечает одному из двух: сколько наговорили или сколько людей, — и растёт бесконечно только второе. Журнал упирается в потолок через месяц, потому что дальше приходящий день сбрасывает день с дальнего края: это и есть срок хранения, увиденный снаружи, а не второй механизм.
The window is the forecast. Two of these lines stop rising a month in because that is when a day arriving starts dropping a day off the back; the third answers to how many people there are rather than to how much they said, and nothing retires it.
Арифметика под верхней строкой — одно умножение. Запись это её шифротекст плюс строка вокруг него — пусть килобайт на сообщение, и это щедро, — так что десять тысяч сообщений в день дают около десяти мегабайт в день, а плато — тридцать таких: триста мегабайт, и там оно и стоит, что бы ни происходило дальше. Вложения — та же сумма при куда большем множителе, и уходят они вместе с записью, которая на них ссылалась. У запечатанной истории своя ручка, ARCHIVE_RETENTION_DAYS, и по умолчанию это тот же месяц, но по другой причине: насколько далеко назад видит устройство, привязанное сегодня.
Следить надо за нижней строкой, потому что её ничто не подметает. Устройство стоит своей строки, своих пакетов ключей, обёрнутого ключа состояния и пуш-токена — несколько килобайт, один раз, — а человек стоит этого за каждое своё устройство. На ста тысячах устройств это всё ещё меньше гигабайта; поэтому здесь строка, а не раздел: трафик выходит на плато, справочник мал, и прогноз, который важен, — про вложения.
Наблюдение
Большая часть того, что в этой службе идёт не так, идёт не так тихо. Пуш, который не удалось отправить, отбрасывается, потому что клиенты сверяются сами. Ведёрко лимита, которое не прочиталось, запрос пропускает. Заполнившаяся очередь сокета отбрасывает события. Каждое из этого — в лучшем случае предупреждение в журнале, а служба продолжает работать, — так что счётчик рядом остаётся единственным, кто говорит, что это происходит. Ради этого числа и заведены, и поэтому список тревог короток: каждая срабатывает на чём-то, что иначе невидимо.
Точка и токен на ней
Метрики отдаются на собственном слушателе — по умолчанию 127.0.0.1:9091, — а не маршрутом на публичном, так что ничто в них не делит с клиентским трафиком ни middleware, ни ограничитель частоты, ни порт. Она всегда за bearer-токеном, включая локальную петлю, и исключений нет: на разделяемой машине локальная петля не граница. Токен короче шестнадцати байт отвергается при старте.
{
"metrics": { "bind": "0.0.0.0:9091" }
}
// The token comes from exactly one of three places, and two of
// them being wrong is a startup error rather than a silent default:
//
// "token": "<the token>" here, in this file
// "token_file": "/run/secrets/metrics" the shape Docker and
// Kubernetes use
// METRICS_TOKEN in the environment what docker-compose does
//
// openssl rand -hex 32Поднять Prometheus и Grafana
Оба уже в compose-файле, за профилем, так что это одно слово в командной строке, а не второй стек, который надо собирать. Ничего из этого не стартует, пока не попросишь.
echo "METRICS_TOKEN=$(openssl rand -hex 32)" >> .env echo "GRAFANA_PASSWORD=$(openssl rand -hex 16)" >> .env docker compose --profile observability up -d # Prometheus http://localhost:9090 # Grafana http://localhost:3000 the dashboard is already there
Между теми двумя строками и Prometheus есть одноразовый контейнер, и стоит знать, зачем он: Prometheus читает свои учётные данные из файла и не раскрывает переменных, поэтому токен из .env ровно один раз становится файлом в томе, который эти двое делят.
Что говорят Prometheus
Каждый экземпляр — своя цель, и опрашивается напрямую, а не через прокси. Это единственная строка конфигурации, которую стоит объяснить: прокси балансирует, поэтому точка метрик за ним отвечает за тот экземпляр, за какой ей вздумалось, — датчик, который случайным образом то делится пополам, то удваивается. Опрашивай каждый, а sum() во время запроса — это то, что снова делает из них флот.
global: scrape_interval: 15s # equal to the scrape interval: an alert is only ever as # fresh as the sample it reads evaluation_interval: 15s rule_files: - /etc/prometheus/alerts.yml scrape_configs: - job_name: chat-delivery metrics_path: /metrics authorization: type: Bearer credentials_file: /etc/prometheus/secrets/metrics_token static_configs: - targets: ["server-a:9091", "server-b:9091"]
Шлюз ботов и кран экспортируют так же, с тем же заголовком и теми же именами метрик, и ни того ни другого в этом compose-файле нет — поэтому их задания выписаны там в комментариях, а не угадываются. Дай каждому свой токен, когда это не одно развёртывание: credentials_file задаётся на задание ровно за этим.
Grafana, заведённая файлами, а не кликами
Источник данных и панель — это файлы, смонтированные только для чтения, с allowUiUpdates: false. Панель, чей источник данных выбрали в браузере, — это панель, ломающаяся на следующем чистом томе; правки в браузере годятся, чтобы попробовать, а следующему развёртыванию достаётся файл. Приезжает chat — delivery service, двадцать семь панелей: жива ли она, сколько экземпляров отвечает против того, сколько их в реестре, живые сокеты, время с последней уборки, запросов в секунду и 95-й перцентиль по маршрутам, троттлинг, доставка.
Is it up
Traffic, and delivery
Requests per second, by route
/groups/:id/log/inbox/connect
sum by (route) (rate(chat_http_requests_total[5m]))95th percentile, by route
/groups/:id/append/blobs
histogram_quantile(0.95, sum by (le, route) (rate(chat_http_request_duration_seconds_bucket[5m])))The group log
messagecommitepoch conflicts
sum by (kind) (rate(chat_log_appends_total[5m]))
sum(rate(chat_epoch_conflicts_total[5m]))Live events
sentdropped: slow socket
sum(rate(chat_ws_events_sent_total[5m]))
sum by (reason) (rate(chat_ws_events_dropped_total[5m]))ЭТО ОТКРЫВАЕТ ДВЕ ДВЕРИ
Prometheus публикуется на 9090 вообще без пароля — а это вся эксплуатационная картина развёртывания, и сам Prometheus ничего не спрашивает. Ему место в приватной сети или за чем-то, что спрашивает.
Grafana стартует с включённым анонимным просмотром, что верно на ноутбуке и неверно где угодно на людях. Выставь GF_AUTH_ANONYMOUS_ENABLED=false, прежде чем она встанет там, где до неё дотянутся; админский пароль для старта и так обязателен.
Что экспортируется
Девяносто одна метрика, и они собираются в одиннадцать семейств. Почти две трети — счётчики, потому что почти всё, что здесь стоит знать, — это скорость, а не уровень; к тому же счётчик переживает перезапуск, читаясь как сброс, а датчик — нет.
| Семейство | Метрики | Что говорят только они |
|---|---|---|
| Запросы | chat_http_requests_total chat_http_request_duration_seconds chat_http_requests_in_flight | Частота, статус и задержка по маршрутам, и сколько обслуживается прямо сейчас. |
| Сокеты | chat_ws_sockets_open chat_ws_events_sent_total chat_ws_events_dropped_total chat_ws_connection_duration_seconds | Что подключено, что протолкнули и что выбросили, потому что сокет не брал, — то самое, что иначе молчит. |
| Журнал | chat_log_appends_total chat_epoch_conflicts_total chat_roster_unchecked_total | Дописанные commit и сообщения, и как часто два устройства гонялись за одной эпохой. Шторм конфликтов — это баг клиента, увиденный с сервера. |
| Между экземплярами | chat_bus_envelopes_sent_total chat_bus_envelopes_received_total chat_bus_faults_total chat_fleet_instances | Раздача между экземплярами и собственный счёт реестра — с ним тревога и сравнивает число тех, кто ответил на опрос. |
| Личность | chat_auth_verifications_total chat_sessions_resolved_total chat_keypackage_claims_total chat_expired_attestations_refused_total | Входы, сессии и то, можно ли ещё кого-то добавить: востребование, не нашедшее пакетов ключей, — это человек, которого нельзя пригласить. |
| Вложения | chat_blob_operations_total chat_blob_bytes_total chat_blob_signed_urls_total | Байты в обе стороны и отказы по видам. Единственное семейство, чей рост не ограничен окном хранения. |
| Под нагрузкой | chat_rate_limited_total chat_rate_bucket_faults_total chat_pow_demanded_total chat_shed_total chat_pressure_level | Что придержали, с кого потребовали работу и на каком уровне сбрасыватель. Ведёрко, давшее сбой, запрос пропускает, поэтому этот счётчик — единственный признак, что так было. |
| Федерация | chat_mimi_calls_total chat_mimi_connections_open chat_peer_redeliveries_total chat_peer_redelivery_gaps_total chat_relay_forwarded_total | Каждый вызов через полосу по точке и результату, обход, догоняющий провайдера, и найденные им дыры — дыра это доставка, которая раньше была бы потеряна. |
| Уборка | chat_maintenance_runs_total chat_maintenance_swept_total chat_maintenance_last_success_timestamp_seconds | Что сбросил каждый проход и когда последний завершился. Остановившаяся отметка времени — это переставшее происходить хранение. |
| Боты | chat_bots_serving chat_bot_api_calls_total chat_bot_api_call_duration_seconds chat_bot_updates_delivered_total | Сторона шлюза: вызовы по методу и результату, переданные обновления и сколько ботов обслуживается. |
| Машина | chat_db_pool_connections chat_background_panics_total chat_metrics_scrapes_total chat_build_info | Пул по состояниям, умершие задачи, опросы, отклонённые из-за плохого токена, и какая сборка отвечает. |
За что стоит будить человека
Четырнадцать правил, и проверку каждое проходило одну и ту же: срабатывает ли оно на том, что иначе осталось бы невидимым. Звонят двое: InstanceDown и больше процента отказавших запросов. Остальные — заявки: реестр экземпляров, разошедшийся со списком опроса; ограничитель, отказавший в открытую; хлопающая раздача; сокеты, теряющие события; остановившаяся уборка; шторм конфликтов эпох; исчерпанные пакеты ключей; недостижимый провайдер; пропавшие блобы; насыщенный пул базы; и кто-то, ковыряющий точку метрик.
- alert: FleetDisagrees # The registry's own count against the number of targets that # answered. They disagree when an instance died without # unregistering, or somebody scaled out and did not tell Prometheus. expr: max(chat_fleet_instances) != count(up{job="chat-delivery"} == 1) for: 15m labels: { severity: ticket } - alert: ServerErrors expr: | sum(rate(chat_http_requests_total{status=~"5.."}[5m])) / sum(rate(chat_http_requests_total[5m])) > 0.01 for: 5m labels: { severity: page }
Одно правило про сами имена метрик: ни идентификатора устройства, ни личности, ни токена, ни слага, ни имени. Метка, называющая человека, — это метка, превращающая панель в запись о том, кто с кем говорил; см. шапку crates/observe/src/lib.rs.
КАК УСТРОЕН СЕРВЕР
Что такое эта служба и чем она отказывается быть
PostgreSQL
MLS
Одно предложение, и всё остальное следует из него: служба доставки упорядочивает сообщения и не может их прочитать. Она не хранилище переписок, не власть над тем, кто в комнате, и не сторона ничьего шифрования. Каждое решение ниже — это либо цена этого, либо то, что за это куплено.
The only decision in the picture is the first one. Everything to the right of it is delivery, and none of it can read what it carries.
Один порядок на комнату, и откуда он берётся
MLS требует, чтобы каждый участник применял одни и те же commit в одном и том же порядке. Commit, принятый не в свой черёд, разваливает группу на два несовместимых состояния ключей, и, в отличие от базы данных, отката нет: применение commit по замыслу уничтожает секреты предыдущей эпохи.
Поэтому вся гарантия упорядочивания — это одно сравнение: строка комнаты блокируется, эпоха отправителя сверяется с эпохой комнаты, и отправитель с устаревшей эпохой получает отказ, а не слияние. Вот и всё. Ни консенсуса, ни разрешения конфликтов, ни примирения — самое дешёвое из верного, и причина, по которой пропускную способность оживлённой комнаты стоит знать заранее, а не выяснять по факту.
Честная цена: commit требует поездки до домашнего сервера комнаты и обратно, а если двое делают commit одновременно, одному скажут попробовать снова. Проект, который вместо этого сливал бы, был бы проектом, неспособным использовать MLS.
There is no merge and no resolution — the whole ordering guarantee is one comparison, and the loser is told to try again.
Следить за комнатой, не держа ключа
Сервер обязан знать, кто в комнате: это те, кому веером раздаётся сообщение. Раньше ему сообщали: отправитель называл устройства, которые только что добавил, и сервер верил. Изменённому клиенту были доступны две лжи. Назвать устройство, которого нет в дереве храповика, — значит подписать его на каждый шифротекст этой комнаты навсегда; прочитать он их не сможет, но узнает, кто с кем и как часто говорит. Умолчать об устройстве, которое есть в дереве, — значит остановить раздачу ему, пока оно остаётся участником: оно отстанет, и его следующий commit отклонят на барьере эпохи. Один участник молча выселяет другого.
Сообщения рукопожатия и так идут открытым текстом, поэтому сервер читает состав из самого дерева, и обе лжи перестают быть выразимыми. Он не узнаёт ничего, чего у него уже не было на диске, и по-прежнему не держит ключа.
The server has to know who is in a room — that is who a message goes to. Being told was the version with two lies in it.
Сначала в тени
Новые правила, способные что-то отклонить, какое-то время работают ничего не отклоняя. Они выполняются, они соглашаются или расходятся, и расхождения считают. Правило, включённое в день написания, — это правило, чьи ложные отказы приходят к тебе в виде чьей-то сломавшейся переписки; правило, которое месяц не разошлось с реальностью ни разу, — это правило, которое можно включать.
A rule switched on the day it is written is a rule whose false refusals arrive as somebody’s conversation breaking.
Журнал — это буфер догона, а не архив
Это решение удивляет чаще прочих, поэтому стоит сказать прямо. Служба держит журнал комнаты достаточно долго, чтобы отсутствовавшее устройство успело его прочитать, и не дольше — по умолчанию тридцать дней. Как только каждый участник прочитал дальше записи, она мёртвый груз, а прямая секретность всё равно означает, что расшифровать её потом уже никто не смог бы.
Так что срок хранения — не эксплуатационная ручка, переодетая в продуктовую. Это максимальный срок, который устройство может пробыть офлайн, прежде чем ему придётся заново войти в свои комнаты, сказанный в днях. История живёт в приложении, на устройствах, запечатанной — или не существует.
Retention is not an ops knob dressed as a product one: it is the longest a device may stay offline before it must re-join its rooms.
Курсор, а не очередь
Здесь ничто не держит ни для кого копии чего бы то ни было. Догоняющее устройство читает журнал с того места, где остановилось; догоняемый другой провайдер — это отметка, докуда ему уже сказали. Нет исходящего ящика, который надо наполнять, нечему утечь, если его так и не разгребли, и нечего примирять, если двое разошлись: журнал и есть истина, а позиция в нём — всё состояние целиком.
Живой сокет — это дверной звонок, а не доставка. Пуш-уведомление — пустая побудка. И то и другое существует, чтобы сократить ожидание; читают же журнал — поэтому пропущенный пуш ничего не стоит и поэтому пуш может не нести никакого содержимого вовсе.
Nothing here holds a copy of anything for anybody. There is no outbox to fill, nothing to leak if it is never drained, and nothing to reconcile.
К чему возвращается устройство, которого не было
Клиент, который переподключился, ни у кого не спрашивает, что он пропустил. Живой канал начинается с чтения почтового ящика, а затем журнала каждой комнаты — с того порядкового номера, который у него есть, — так что всё случившееся, пока его не было, приходит теми же самыми сообщениями, какие он получил бы тогда, и в том порядке, в каком они были написаны. Управляющие сообщения — часть этого: комнату переименовали, у комнаты сменилась картинка, кто-то объявил свой профиль — имя, фотографию, строчку о себе, — выложили ролик, посмотрели ролик. Они применяются по порядку, поэтому состояние, в котором устройство оказывается, — это то состояние, в котором всё это время были остальные.
Объявление, которое ничего не меняет, применяется, но не показывается. Комната заново сообщает, как она называется и как выглядит, каждый раз, когда кого-то добавляют, — участник, вошедший на девятой эпохе, не может прочитать сказанное на первой, — поэтому все, кто уже внутри, слышат это и во второй раз, и в третий. Клиент пишет строку в переписку только тогда, когда значение, которое у него хранится, действительно изменилось.
Чего повтор журнала починить не может — так это того, чего в журнале уже нет. Срок хранения — это ограничение на то, как долго устройство может отсутствовать, а не ограничение на историю: за этой чертой устройство входит в свои комнаты заново, а не вычитывает их, и объявление, достаточно старое, чтобы быть обрезанным, повторить уже нельзя — устаревшая за ним копия профиля осталась бы устаревшей навсегда. Ролики истекают сами через сутки, так что выложенный и пропавший, пока устройство было выключено, просто никогда не будет увиден, — в этом и смысл роликов.
Поэтому каждое устройство при подключении заново публикует свой профиль — но не чаще раза в сутки. Это одно управляющее сообщение на комнату от единственной стороны, которая знает факты, и это значит, что любая устаревшая копия имени, фотографии или описания чинится сама в течение суток после того, как обе стороны побывали в сети, — без того, чтобы кто-то у кого-то что-то спрашивал, и без справочника «кого как зовут» на стороне службы.
Два экземпляра, и почему второй достаётся даром
Всё, что несёт корректность, лежит в Postgres, поэтому экземпляры взаимозаменяемы, и прокси перед ними не нужна привязка сессий. Один экземпляр — тот случай, который работает случайно.
Единственное, что из этого не следует, — раздача веером: сообщение, приземлившееся на экземпляр B, должно дойти до сокета, который держит A. Это делали через уведомления Postgres, и довод был хорош — ничего нового в эксплуатации. Упустили же, что уведомление — это широковещание: каждый экземпляр разбирал каждое событие во всём развёртывании, чтобы найти те немногие, для которых он держит сокеты, — так что десять экземпляров делали десятикратную работу ради доставки тех же сообщений. Это ровно противоположно тому, ради чего покупают второй экземпляр. Сейчас это исправлено, и ведёрки лимитов переехали тем же путём: одно ведёрко на субъект на весь флот, так что числа в файле — это числа, которые действуют, сколько бы экземпляров ни работало.
A notification is a broadcast: every instance parsed every event to find the few it held sockets for, so ten instances did ten times the work.
Шардирование, которое не федерация
Two different things are easy to confuse here, and the difference is who is on the other side. Federation is two providers who do not trust each other exchanging signed messages — different operators, different databases, a protocol between them. Sharding is one provider whose database is cut into pieces: one operator, one deployment, one set of rules, and the pieces answer to the same code. A room does not know which shard it is on, and no client ever addresses one.
What makes the cut possible is the same fact that makes the room’s home work: the only guarantee that has to be strict is one commit per epoch, and that is a compare-and-swap on one row. Everything the write path touches — the room, its roster, its log — is keyed by the room. So a room fits inside one piece whole, and no transaction has to cross anything.
There are two natural keys, not one: rooms, and people. Devices, sessions, key packages, sealed state and the inbox all follow the identity that owns them, so logging in and checking whether a device is still one its identity stands behind stay in one place too. The seam is where a question about a room needs an answer about a person, and there was one such question on every read and every append — is this device’s owner in this room. The roster stores the identity now, so the room answers it alone.
That was worth doing before any piece was cut. It removes a join from the hottest query in the service, which is a plain win on a single database — and the ability to divide the data later is the consequence rather than the reason.
Когда запрос пришёл не на тот сервер
У комнаты один дом, вписанный в её идентификатор. На запрос о комнате, которую упорядочивает кто-то другой, отвечают 421 с меткой того дома, который это делает, — не 404, потому что комната существует и запрос составлен верно, и не пересылкой, потому что сервер, тихо упорядочивший чужую комнату, породил бы работающую переписку, которой никто больше не увидит. Это худшая форма, какую может принять ошибка маршрутизации. Клиент перечитывает справочник и спрашивает у правильного; никому ничего не сообщают.
A server that quietly ordered somebody else’s room would produce a working conversation nobody else could see — the worst shape a routing bug can take.
Хранение и уборка
Уборка идёт каждые шесть часов. Старые данные журнала уходят сбросом целого месячного раздела, а не удалением строк — разница между мгновенно и очередью на вакуум, которая через несколько дней приходит загадочной медлительностью.
| Что | Когда уходит |
|---|---|
| Разделы журнала | после LOG_RETENTION_DAYS — по умолчанию 30 |
| Вложения и файлы на диске, на которые не ссылается ни одна строка | вместе с журналом, которому принадлежали; сироты подметаются отдельно, потому что файл, на который ничто не указывает, невидим для любой другой проверки |
| Запечатанные окна истории | после ARCHIVE_RETENTION_DAYS — собственная ручка, хотя по умолчанию тот же месяц: одна говорит, сколько устройство может отсутствовать, другая — насколько далеко назад видит только что привязанное |
| Строки входящих, потраченные пакеты ключей, истёкшие сессии и вызовы | когда их уже ни для чего нельзя использовать |
| Сессии устройства, у которого кончилась аттестация | считаются отдельно от сессий, истёкших сами по себе: действующая сессия у устройства, которое больше не действует, — это другой факт |
| Невостребованные посылки привязки | через пятнадцать минут — достаточно долго, чтобы принести второе устройство из соседней комнаты, и достаточно коротко, чтобы запечатанный ключ не остался лежать на сервере из-за брошенной на полпути привязки |
| Потраченные одноразовые числа реле | после окна, в котором их конверт мог быть предъявлен, — единственное, что ограничивает таблицу, в которую пишет каждый переданный через реле запрос |
Каждое из этого считается, и счётчики здесь суть дела, а не любезность: остановившееся число — это то, по чему оператор узнаёт, что уборка перестала идти. Уборка, которая молча остановилась, выглядит ровно как развёртывание, которым никто не пользуется.
The circle is the lifetime. Two of them are a clock and the rest are an event — spent, expired, collected — and none of them is an archive.
ОГРАНИЧЕНИЯ
Ограничения
Redis
Proof of work
Регистрация открыта и самообслуживаемая. Аттестация самоподписана, поэтому сделать устройство не стоит ничего, и сделать их тысячу тоже не стоит ничего — ничто из этого не взлом, и всё это чужая машина, забитая под завязку. На пути стоят три отдельные вещи, и они отвечают на три разных вопроса.
| Что | На какой вопрос отвечает |
|---|---|
| Лимиты частоты | Что можно одному вызывающему, кто бы ещё тут ни был. Ведёрко токенов на действие, в Redis, а не в каждом процессе, — так что флот из десяти соблюдает файл один раз, а не десять. |
| Доказательство работы | Сколько стоит объём. Ниже человеческой скорости — ноль; выше — удвоение. Это то, чего не умеет лимит частоты, потому что наделать больше устройств значит его обойти. |
| Перегрузка | Что сервер сделает вообще, кто бы ни спрашивал. Развёртывание может укладываться в каждый лимит на вызывающего и всё равно остаться без диска: сто человек, ведущих себя безупречно, — это всё равно сто человек. |
Работа — и только когда кто-то обходится дорого
Ниже разрешённого требование равно нулю: не маленькая головоломка, а никакой; ни заголовка, ни затрат. Выше него каждый шаг объёма добавляет бит, а бит — это удвоение. Смысл в форме кривой: у того, кто печатает, скорость задана человеческой рукой, у того, кто вставляет, размер задан человеческим терпением, а у скрипта нет ни того, ни другого.
Значения по умолчанию взяты из опубликованной статистики, а не угаданы. Пользователь мессенджера отправляет порядка сорока сообщений в день примерно за тридцать пять минут в приложении — это около одного в минуту, пока он им действительно пользуется. И длину сообщения там же измеряли не раз: среднее 58,7 символа, медиана 50,5, мода 32. Поэтому разрешённое стоит на шестидесяти сообщениях и 256 КиБ в минуту: примерно в пятьдесят раз больше того, что делает человек, и меньше секунды работы для цикла.
Доказательство покрывает вращающееся зерно сервера, устройство и ровно те байты, что отправляются. Без зерна его можно было бы посчитать за неделю вперёд; без устройства одна решённая головоломка платила бы за тысячу устройств; без тела оно было бы переиспользуемым, а это снова потоп с одной решённой головоломкой. Отказ несёт все три, чтобы клиент мог ответить, а не угадывать, — и отвечает он один раз, потому что второе требование означает, что сложность выросла, пока работа шла, и вечно это повторять — значит греть телефон вместо того, чтобы показать ошибку.
Это не защита от того, кто готов потратиться на железо, и она на это не претендует. Она меняет экономику дешёвого случая — ноутбук, цикл и нечего терять, — а это тот случай, который на самом деле и случается.
Nothing is asked of an ordinary sender. The cost rises with whichever of the two lines — messages or bytes — is further past its allowance, and stops at a ceiling a phone can still meet.
Сдавать по очереди, а не падать
Сервер, у которого кончился процессор, не отказывает вежливо. Он замедляется, потом ещё, потом проверка живости не дожидается ответа и что-то перезапускает его посреди чужого разговора. Отказ загрузить видео — то, что один человек замечает на минуту; падение — то, что все замечают на десять.
Три показания, и решает худшее из них, а не среднее: машина с запасом процессора и без диска не наполовину в порядке. Средняя нагрузка на ядро — чтобы один файл значил одно и то же на ноутбуке и на машине с шестьюдесятью четырьмя ядрами. Свободное место там, куда пишутся вложения, — у него единственного есть нижняя граница, потому что полный диск ломает сразу всё, включая базу. И байты, которые двинула эта служба, а не то, что сделала сетевая карта: в это входят все остальные на машине, и отвечать за них не нам.
| Уровень | Что перестаёт предлагаться |
|---|---|
busy | Вложения становятся меньше. Фотография ещё проходит, видео — нет. |
congested | Вложения ещё меньше, и никаких новых звонков. Уже идущие звонки не трогают: рвать людей на полуслове ради процессора, который и так уже потрачен, не даёт ничего. |
critical | Ни вложений, ни голосовых. Только текст и сигналинг. |
Порядок и есть аргумент: сначала самое большое и наименее срочное, и текст не отклоняется ни на одном уровне. У него нет ручки в файле — так он гарантирован, а не настроен: мессенджер, который не может донести предложение, не деградировал, он остановился.
Голосовое — единственное, о чём сервер не может судить сам: он держит шифротекст, а голосовую заметку по размеру не отличить от любого другого звука. Поэтому /info говорит, чего сейчас не предлагают, и приложение прячет кнопку, а потолок размера остаётся страховкой, а не правилом. Кнопка, которая ломается при нажатии, неотличима от бага.
Shedding in a stated order beats falling over: the parts that cost the most go before the part everything else depends on.
Один файл, и он может быть TOML
Все три живут в одном файле — на него указывает CHAT_SERVER_CONFIG, а если не задан, то ./server.json. Язык выбирается по имени файла, а не попыткой разобрать и откатом: файл TOML с ошибкой, о котором доложат как о плохом JSON, — это сообщение не про тот язык, не про ту строку, и человеку, который никакого JSON не писал.
[pow] free_messages = 60 # ~50x what a person does in a minute free_bytes = 262144 # a message is tens of bytes of text messages_per_bit = 20 # each further step doubles the work max_bits = 22 # a few seconds on a phone, and no more [congestion] cpu_critical = 4.0 # load average per core disk_free_critical = 536870912 # 512 MiB — the last chance to stop max_upload_busy = 8388608 # 8 MiB — a photo goes, a video does not refuse_calls_at = "congested" refuse_voice_at = "critical"
- Каждое поле необязательно. Называй только те, что хочешь сдвинуть; отсутствующий файл — обычный случай, он означает значения по умолчанию. А вот файл, который есть и не разбирается, — ошибка при старте, потому что пределы, которые молча не загрузились, — это пределы, которых никто не соблюдает.
- Раздел про перегрузку умеет только сужать. Он никогда не поднимает потолок — иначе настоящий максимум живёт в двух местах, и меньший из них не всегда тот, что действует.
- Эти числа — заглушки с приложенным к ним обоснованием. Опубликованные средние — это чужие пользователи в чужих продуктах. Служба записывает, что делают твои:
chat_messages_per_device_minute,chat_message_bytes,chat_pressure_level, — чтобы следующий набор можно было прочитать с работающего развёртывания, а не процитировать. Растущее число отказов при низких гистограммах означает, что разрешённое слишком тесно, а не что кто-то устроил потоп.
ФЕДЕРАЦИЯ
Больше одного сервера, и что между ними должно быть правдой
MIMI
MLS
Федерация здесь — не сеть равных, передающих друг другу копии. Это одно предложение: у каждой комнаты ровно один сервер, решающий её порядок, а всё остальное — доставка. MLS требует одного commit на эпоху, и эта гарантия есть compare-and-swap над единственной строкой — её нельзя распределить, её можно только назначить. Поэтому интересный вопрос никогда не в том, кто с кем согласен; он в том, кого назначили, как об этом узнают все прочие и что происходит с теми, кто находится в другом месте.
Протокола три, и какой из них звучит, зависит от того, кто на каждом конце. Устройство говорит с провайдером, у которого у него аккаунт, по обычному HTTPS, и один WebSocket нужен для того, о чём ему должны сообщить, а не о чём оно спрашивает. С другим провайдером оно говорит так же и напрямую: завести там аккаунт — это аттестация и подпись, а не одолжение, которое кто-то должен оказать. Провайдеры говорят между собой на draft-ietf-mimi-protocol поверх взаимного TLS, по точкам, которые каждый вычитывает из справочника другого.
A device is not tied to one server: it holds an account wherever a room it is in is ordered, because proving who it is takes an attestation and a signature rather than anybody’s permission. Between operators the protocol is a different one, and the lane it runs on is opened by agreement rather than by a certificate the world will issue.
Пунктирная линия — единственный запасной путь на рисунке: дом, до которого это устройство вообще не дотягивается, достигается через свой собственный, и тот несёт запрос, который не может ни прочитать, ни изменить, ни повторить. Прочитай сплошную ось слева направо — и это же будет другой порядок: комната, которую упорядочивает другой провайдер, за которой здесь следят от имени устройства и читают локально, как любую другую. Какой из двух вариантов у комнаты, решается при приглашении, а не её идентификатором.
У комнаты один дом, и её имя говорит какой
Назначение обязано читаться всяким, у кого есть идентификатор комнаты, — иначе где-то существует всемирный реестр комнат, а именно его и избегают. Поэтому оно в идентификаторе, в байтах, которые обе стороны читают одинаково.
A domain here would be permanent — the identifier is signed into every commit — and would tell everyone who sees one which operator runs the room. A label costs a directory file instead. The counts are printed because the two narrow fields are drawn wider than their share, to fit a label in.
Дом — это непрозрачная трёхбайтовая метка, а не домен. Домен был бы вечным: идентификатор живёт в групповом контексте MLS и подписан в каждый commit, так что переименование или переезд оператора означали бы пересоздание каждой его комнаты. Он ещё и сообщал бы всякому, кто когда-либо увидит commit, какой оператор ведёт комнату. Метка стоит небольшого справочника, отображающего её в адрес, и этот справочник — файл, который любой волен править.
Байт версии стоит там ради изменения, которого никто не переживает: правила, сдвигающиеся под байтами, которые по-прежнему разбираются. Больше это здесь ничем не ловится — сервер не читает, что внутри комнаты, а MLS не отличает прикладное сообщение от любого другого. Поэтому правила, при которых комната создана, путешествуют вместе с её именем, и байт объявляет, а не принуждает: отказы стоят у двух дверей, которые ведут внутрь комнаты, а не на пути чтения, потому что правило, применённое к комнате, уже полной разговора, уносит разговор с собой.
Спроси не тот сервер — и он ответит 421 с меткой того, который нужен: никогда не 404 и никогда пересылкой, по причинам, изложенным в разделе про не тот сервер. Этот отказ ещё и тот, на который клиент может отреагировать в одиночку: идентификатор называет свой дом в байтах, о которых обе стороны договорились, — значит, 421 не может означать, что клиент ошибся домом, только что метка разрешилась в неверный адрес. Он перечитывает справочник и спрашивает снова, один раз. Второй 421 после свежего справочника — это флот, разошедшийся сам с собой, и повторять в это значит получить клиента, который висит, вместо того чтобы сообщить оператору то, с чем можно что-то сделать.
Полоса между провайдерами
Два экземпляра одного развёртывания взаимозаменяемы и делят базу; два оператора не делят ничего. Второй случай — это протокол на проводе, и звучит на нём draft-ietf-mimi-protocol, работа IETF по совместимости мессенджеров. Пути не зашиты: узел читает /.well-known/mimi-protocol-directory и находит точки там. Каждое поле в нём необязательно — так узел узнаёт, чего провайдер не предлагает, что полезнее, чем выяснить это из 404 на середине хода.
Every path is discovered rather than assumed: a peer reads the directory at a well-known address and finds out what is on offer there, including what is not.
Аутентификация — взаимный TLS, и корни доверия узлов свои, у развёртывания, а не системные. Узел — это тот, с кем этот оператор согласился федерироваться, а «любой сертификат, который выдадут в мире» такой договорённостью не является.
- Наполовину настроенная полоса — ошибка при старте, названная переменная за переменной и сразу вся. Иначе это полоса, ломающаяся на первом соединении с узлом, где поломка принадлежит чужому запросу и выглядит их бедой.
- Домен без работающей полосы отдаёт пустой справочник и предупреждает. Объявлять точки, к которым никто не подключится, — то самое обещание, ради неподачи которого справочник и существует.
- Сертификат говорит, какой провайдер, а не кто спросил. Поэтому востребование ключевого материала должно быть дополнительно подписано тем ключом, который оно называет. Востребование тратит одноразовый пакет: без проверки узел мог бы составлять запросы от имени любого своего пользователя и выжечь пакеты людей этого развёртывания. Что именно устанавливает подпись, сказано не сильнее, чем есть: что тот, кто составил запрос, владеет этим приватным ключом. Не то, кому ключ принадлежит, — потому что удостоверение, выданное другим провайдером, это удостоверение, которое данное развёртывание проверить не может.
- Адреса можно сообщить, а не разрешать.
MIMI_PEER_ADDRESSESприбиваетdomain=host:portтам, где DNS не ответ. Прибивается адрес и никогда не личность: сертификат по-прежнему сверяется с именем, так что направить домен куда-то не делает то, что там отвечает, этим доменом.
Добавить кого-то у другого провайдера
Ни один из переходов не тот, который добавляющий клиент мог бы сделать сам. Их ключевой материал востребуется через их собственного провайдера, их лист вносится commit'ом в дерево здесь и упорядочивается на узле-хабе, и только потом Welcome уходит, чтобы его донесли.
The order is the whole of it. Somebody who accepted an invitation to an epoch the hub never accepted is a member of a group nobody else is in.
Порядок — это всё. Востребовать, внести commit, затем Welcome: тот, кто принял приглашение в эпоху, которую хаб так и не принял, — участник группы, в которой больше никого нет.
- Кого извещать — вычитывают, а не принимают на веру. Провайдеры, которых касается комната, берутся из дерева храповика, никогда из запроса. Клиент, назвавший свой список, мог бы заставить этот сервер доставить Welcome комнаты провайдеру, к которому никто из комнаты не принадлежит, — сообщив ему, что комната существует, кто в ней, и вручив шифротекст на хранение.
- Кому предназначен Welcome — вычисляют, а не заявляют. Его секреты адресованы ссылкам на пакеты ключей, а это собственное имя MLS для присоединяющегося, так что раздача никого не называет и не обязана. Ссылка, которой ничто не соответствует, пропускается — это обычная форма Welcome, адресованного нескольким людям у нескольких провайдеров.
- Чужой пакет ключей едет той же дорогой, что и местный, и потому встречает ровно ту же проверку. Именно это и делает чужой лист безопасным для дерева вообще.
- Раздача принимает Welcome и громко отклоняет остальное. Commit или прикладное сообщение потребовали бы от этого развёртывания держать журнал комнаты, которую оно не упорядочивает. Узлу, которому сказали, что его сообщения дошли, и который потом обнаружил, что их никто не прочитал, солгали.
Следить за комнатой, которую упорядочивает кто-то другой
За комнатой, которую это развёртывание не упорядочивает, оно следит, и всё вытекает из этого единственного различия. Второго барьера нет: барьер существует, чтобы порядок решал один сервер, а для отслеживаемой комнаты этот сервер — хаб. Записи приходят от него, дописываются в том порядке, в каком пришли, и нумеруются локально, потому что местная последовательность — это то, по чему читает здешний клиент. Содержимое на веру не берётся: каждый commit и каждое сообщение проверяются собственным состоянием MLS у каждого участника ровно так, как если бы их упорядочили здесь.
The barrier exists so that one server decides the order. A second one here would be a second opinion about a question with one answer.
- Своя же копия должна вернуться узнаваемой. Хаб возвращает записи без отправителя, что верно для чужих и неверно для собственной: MLS не даёт отправителю прочитать свой же шифротекст. Поэтому единственное, что этот провайдер знает, — что он нёс именно эти байты для этого устройства, — запоминается под хешем шифротекста на время поездки туда и обратно.
- Клиенту нужно сказать, где читать. Ничто в идентификаторе этого сказать не могло бы: вопрос не в том, чья метка, а в том, кто держит копию. Флаг сохраняется при входе, из Welcome.
- Commit не проталкивается провайдеру, которого он добавляет. Им ещё не сообщили, что комната существует — приглашение это отдельный переход, и так и должно быть, — поэтому раздача использует дерево до этой записи. Дерево они получают вместе с Welcome.
- Изменение комнаты идёт через тот же барьер. Хаб проверяет то же, что проверяет MLS: что у спрашивающего провайдера есть кто-то в этой комнате. Не больше, потому что внутри комнаты commit может делать любой участник, а что commit вправе сделать, решает каждый участник при проверке, а не мнение сервера. И не меньше, потому что иначе он упорядочивает чужие commit в чью-то комнату.
- Присоединившемуся говорят, откуда начинать читать — сразу после того commit, который его добавил, а это знает только хаб. Отправить его к началу журнала значит вручить ему сообщения из эпох, в которых его никогда не было, а MLS их отвергнет, правильно и бесполезно.
Провайдер, который лежал
Всё, что касается федерации, сначала доказывалось внутри одного процесса: два сервера, две базы, локальные порты и удостоверяющий центр, который тест выдумывает сам. Запуск их как двух настоящих процессов показал то, чего та схема показать не могла. Останови одного провайдера, отправь сообщение в комнату, где есть кто-то оттуда, запусти его снова — сообщения нет, навсегда.
Рассуждение, которое это допустило, верно для того случая, для которого написано. Раздача никогда не была гарантией доставки, потому что гарантия — журнал: участник, пропустивший пуш, читает журнал и догоняет. Для участника у другого провайдера это не работает: копия комнаты у его провайдера и есть эти пуши. Второго источника, из которого догонять, нет, поэтому пуш, не прошедший, пока они лежали, — не задержанное сообщение, а потерянное, и об этом нигде не было сказано.
A fanout was never the delivery guarantee, because the log is — and for a member at another provider that reasoning does not hold. Their provider’s copy of the room is built from those pushes.
Починка — курсор, а не очередь: одно число на комнату и провайдера, говорящее, докуда этому провайдеру уже сообщили. Пуш двигает его, только если доставленная запись ровно следующая — успех не по порядку курсор не трогает, так что дыру нельзя перешагнуть, — а обход предлагает всё, что позади, по порядку, и останавливается на первом отказе. Ничто не ставит в очередь полезную нагрузку, потому что журнал и так держит каждую запись; границей поэтому служит его собственный срок хранения, а запись, у которой он вышел, никаким числом повторов доставлена бы не была. Строки берутся через for update skip locked, так что экземпляры делят проход, а не гоняются, и провайдер, у которого ушёл последний участник, забывается на следующем commit — знает об этом только дерево.
Ценно тут не про курсор. Ценно, что правило, верное в одном месте, перенесли туда, где оно неверно, и показать это могли только два настоящих процесса.
Когда клиент не дотягивается до дома
Устройство, которое не может открыть соединение с домом комнаты, доносит туда его собственный дом. Этого долго не делали по хорошему доводу — говорить с домом напрямую не требует доверия между серверами, а пересылка вроде бы требует, — и довод, как выяснилось, направлен против поручительства, когда сервер утверждает «это Боб». К устройству, которое подписывается само за себя, он не относится.
The carrier holds bytes. It cannot forge a request, alter one, move it to another route or another home, or send it twice — and it cannot sign for the device whose key it can plainly see.
Конверт покрывает метод, путь с запросом, тело, отметку времени, одноразовое число и тот дом, которому адресован, и подписан тем же ключом устройства, который доказывает владение при входе. Носильщик держит байты: он не может ни подделать запрос, ни изменить его, ни перенести на другой маршрут или в другой дом, ни отправить дважды — и не может подписаться за устройство, чей ключ он преспокойно видит в каждом проходящем через него конверте.
- Сначала напрямую, при неудаче — через носильщика. Откатывается только неспособность дотянуться до дома. Дом, который ответил — отказом, 401, чем угодно, — дал ответ, и спросить снова по более длинной трубе значит получить тот же.
- Маршрут, умерший посреди разговора, тоже восстанавливается. Это обычное состояние телефона, меняющего сети, поэтому этот случай важнее прочих: соединение, переставшее отвечать, заменяется несомым, и несомое заменяет запись в кэше, потому что только что отказавший маршрут — не тот, который стоит по-прежнему пробовать первым.
- Один запрос при смерти маршрута теряется, намеренно. Соединение, умершее на середине отправки, тоже не ответ, но повторять его всё равно небезопасно: байты могли дойти и быть исполнены. Поэтому запрос, бывший в полёте, сообщают вызывающему, а несут только те, что после него; отправлять ли его снова — решение вызывающего, потому что только этот слой знает, безвредно ли это.
- Нового доверия не нужно, по трём причинам. Идентификатор устройства живёт внутри его аттестации, подписанной корневым ключом, поэтому список участников означает одно и то же в каждом доме. Ключи в строке устройства никогда не переписываются, поэтому дальний дом читает, какому устройству принадлежит ключ, из своих же записей, а не из заявления отправителя. И регистрация анонимна, поэтому первый несомый запрос от неизвестного ключа — тот самый, что создаёт строку: он проходит как ничей, а аутентифицированный маршрут затем отклоняет его за отсутствием сессии.
Чего перенос не скрывает: носильщик видит, с какими домами у этого устройства дела, когда и сколько. Это цена запасного пути и причина, по которой он запасной, а не обычная дорога. Носить всегда значило бы разменять метаданные в другую сторону, и это отдельное продуктовое решение, а не недостающая часть этого.
Чего это не делает
- Комнату нельзя переместить. Её дом в её идентификаторе, а этот идентификатор подписан в каждый commit, который она когда-либо сделает. Вместо этого есть преемственность: новая комната в новом доме с теми же людьми, объявленная изнутри старой по каналу, который MLS уже аутентифицировал. Старый дом для этого не нужен, кроме как чтобы сказать: ключевой материал каждого участника живёт у него дома, а его дом записан в аттестации в дереве, которое переезжающее устройство и так держит. Запланированные переезды обрабатываются целиком; дом, исчезнувший без предупреждения, оставляет тех, кто по-прежнему на него нацелен, без вестей, и с этой стороны такого не чинит никакой протокол.
- Согласие — это типы, а не политика. Внутри одного развёртывания любой может востребовать чьи угодно пакеты ключей, и довод в том, что востребование — это истощение, а не разглашение. Между провайдерами это рассуждение перестаёт держаться: провайдер незнакомца может целыми днями истощать чьи-то пакеты и заодно узнавать, какие у человека устройства. Запрос и ответ написаны; не написано правило, по которому решать.
- Франкирование — открытый вопрос, и ответ может быть «нет». Пожаловаться третьей стороне значит криптографически доказать, что сообщение было отправлено, — свойство, которое этот проект до сих пор предлагать отказывался и которое MIMI требует для совместимости.
- Удостоверение от другого провайдера не проверяется. Подписи сверяются; кому принадлежит ключ, устанавливается только между узлами, использующими собственные аттестованные удостоверения этого проекта. Это общая проблема MIMI, а не этого развёртывания, и проверки задокументированы ровно с той силой, которая у них есть.
ДЕНЬГИ, КОТОРЫЕ ДЕРЖИТ ГРУППА
Групповой кошелёк и то, что за него подписывает
Multisig
Bitcoin
Ethereum
TRON
Solana
Safe
Squads
Кошелёк, который держит группа, — единственное в этом списке, что не наше. У каждой сети уже есть способ, которым несколько человек держат один адрес, и здесь используется именно он, а не надстройка поверх: P2WSH в Bitcoin, Safe в EVM-сетях, собственная система permission у аккаунта в Tron, Squads V4 в Solana. Четыре крейта в wallet/crates/multisig/, по одному на сеть, за единой точкой входа JSON‑RPC, где каждый метод помечен приставкой той сети, которой принадлежит: bitcoin_*, evm_*, solana_*, tron_*.
Для всего остального на этой странице важно, где лежат ключи. Нет ни службы кошелька, ни хранения: ключ каждого подписанта остаётся на его собственном устройстве, адрес строится сразу из них всех, а порог — правило группы, а не настройка на сервере. Ничто из того, что крутит это развёртывание, не может двинуть эти деньги, потому что ничто из этого никогда не держало к ним ключа — та же фраза, что и про службу доставки, которая несёт шифротекст и не может его прочитать, только сказанная про монеты, а не про сообщения.
Четыре шага, и только последний происходит в сети: группа фиксирует правило, кто-то предлагает платёж, каждый подписант подписывает на своём устройстве, и на пороге платёж уходит. Три шага до последнего — это сообщения в той самой переписке, где о платеже спорили; поэтому платёж здесь и через месяц несёт при себе свою причину, а платёж с биржи — нет.
Всё целиком — расписанный ход, то, как каждая сеть называет «несколько человек, один адрес», и те развёртывания, с которыми приложение действительно говорит, — вынесено на отдельную страницу. Это страница о продукте, а не справочный раздел, и свернуть её сюда значило бы вставить главу про деньги в середину документа про серверы.
БРАУЗЕР DAPP
Браузер внутри приложения и страницы, которые просят кошелёк подписать
Ethereum
EIP-1193
TRON
Solana
Multisig
Состояние дел, чтобы дальнейшее не читалось как готовое: фазы с 0 по 4 написаны и проходят свои тесты, и ни один настоящий дап через это ещё не открывали. Модульные тесты не покрывают именно ту часть, которая обязана быть верной: момент внедрения, круговой ход обработчика и то, узнает ли настоящая сборка этого провайдера.
Веб-страница не может дотянуться до приватного ключа и не должна мочь. До чего она дотягивается — до объекта, положенного в window до загрузки страницы, каждый метод которого есть сообщение чему-то за её пределами. Поэтому интересное здесь — не WebView. Интересен маршрутизатор, решающий, какие из множества возможных запросов дапа отвечаются из локального состояния, какие уходят на публичный узел, а какие останавливаются и ждут человека.
Как кошелёк слушает страницу
Механизма два, и они сосуществуют, потому что старый нельзя отозвать.
Two mechanisms, and they coexist because the old one cannot be withdrawn: a global the page reads the moment it loads, and an event pair that lets several wallets answer at once and the page choose. The dot on each line marks the side being arrived at.
- Глобальная переменная, читаемая в момент загрузки страницы. Страница, синхронно спрашивающая при загрузке «есть ли тут кошелёк?», читает
window.ethereumи получает то, что там есть в это мгновение. Поэтому внедрение обязано происходить в начале документа, в собственном мире страницы, до любого её скрипта: провайдер, появившийся после того, как сборка уже спросила, — это провайдер, которого дап никогда не увидит. Он определяется черезObject.definePropertyи не подлежит переопределению, так что страница со своей прокладкой не заменит его втихую. - Пара событий — на случай, когда кошельков больше одного. EIP-6963 существует потому, что одна глобальная переменная не вмещает два кошелька: кто записал последним, тот и победил, молча. Поэтому кошелёк объявляет себя при загрузке и объявляет снова каждый раз, когда страница испускает
eip6963:requestProvider, и каждое объявление несёт имя, значок и идентификатор в обратной DNS-нотации — именно это перечисляют современные окна подключения. Значок передаётся как data URI, так что перечисление кошельков ничего не тянет из сети.
Поэтому же isMetaMask здесь ложен. Многие дапы до сих пор на нём ветвятся, и выставить его истинным — это ложь, по которой страница потом действует, та самая ложь, которую говорят все остальные кошельки. Есть переключатель на сайт, по умолчанию выключенный и подписанный тем, что он есть, — для страниц, жёстко упирающихся в этот флаг. Глобальная истина по умолчанию говорила бы неправду каждой странице, нужно ей это или нет.
Что представляет собой внедряемый объект и чем он не является
Тонкая скучная прокладка: объект EIP-1193 со старыми обёртками send / sendAsync / enable, которые до сих пор зовут старые библиотеки; состояние закэшировано, так что обычные вопросы не стоят похода туда-обратно; события выталкивает приложение, а не выводит скрипт. chainChanged испускается после того, как приложение переключилось, и никогда наперёд.
It validates nothing, decides nothing and holds nothing. A rule that appears in the script and not in the app does not exist — the page shares a JavaScript context with it and can call the handler directly.
Каждый вызов становится одним сообщением через мост и возвращается результатом EIP-1193 или ошибкой EIP-1193 — {code, message, data}, со стандартными кодами, используемыми честно: 4001 только когда человек действительно нажал «Отклонить», 4100 когда источнику не выдано ни одного аккаунта, 4200 для метода, которого мы не реализуем, -32602 для неправильных параметров.
Скрипт никогда не держит секрета и никогда не принимает решения. Если правило появилось там, а не в приложении, этого правила не существует: страница делит с ним контекст JavaScript и может позвать обработчик напрямую.
Четыре ответа и белый список
Классификация — белый список, а не чёрный: метод, о котором никто не подумал, отклоняется, а не пересылается и не угадывается.
A method nobody has thought about is refused, never forwarded and never guessed at. The way this class of code fails open is somebody adding a passthrough default, so each refusal has a test of its own.
- Неподключённому источнику не говорят ничего.
eth_accountsвозвращает пустой список, пока источнику не выдали аккаунт, — это предписанное поведение, а не любезность. - Пересылка не заводит второй настройки. Вызовы на чтение уходят через ту же точку, что уже настроена для этой сети, ту самую, которой пользуются балансы, вместе с любым переопределением.
- Два отказа названы, а не просто не внесены в список.
eth_signподписывает непрозрачный 32-байтовый хеш — примитив, стоящий за фишинговой страницей «вынесите всё», и он не нужен ни одному честному дапу.eth_sendRawTransaction— это публикация, а публикация дело кошелька: страница, которой нужно отправить транзакцию, просит её подписать. У каждого отказа свой тест, потому что этот класс кода отказывает в открытую тогда, когда кто-нибудь добавляет сквозной проброс по умолчанию.
Что человеку показывают перед подписью
Всё, что кошелёк подписывал до этого, составлялось на его собственном экране. Подписать то, о чём попросил незнакомец, — другое занятие в той же одежде, поэтому известное показывают, а о неизвестном говорят, а не умалчивают.
What is known is shown; what is not known is said rather than left out. A screen that looks complete and is not is the failure this whole part is arranged against.
- Вход, чей домен не совпадает с этим сайтом, останавливают. Когда сообщение разбирается как EIP-4361, его
domainсверяется с источником, и расхождение останавливает лист, а не предупреждает под ним: именно эта подпись входит за кого-то на сайт, на котором его нет. - Безлимитное одобрение — стандартный способ, которым кошельки опустошают. Если разбирается один вызов, то этот, и
approveна максимальную сумму сопровождается явным предупреждением. Всё неразобранное показывается как неизвестный вызов со своим селектором и сырыми данными — честно, а не дружелюбным пересказом того, чего никто не читал. - Источник перечитывают прямо перед подписью. Не берут на веру с момента открытия листа, и запрос отменяется, если страница уходит на другой адрес или источник меняется, пока лист открыт.
Источник — это схема, хост и порт, вычисленные по тому URL, который WebView действительно принял, и никогда по тому, что страница говорит о себе сама. Только HTTPS плюс локальная петля для разработки: страницу, доставленную по обычному HTTP, может переписать на лету любой, кто на пути, а этой странице вручают аккаунт. Выданное разрешение и сохранённые сайтом данные — две разные вещи, которые стираются по отдельности, и настройки это говорят.
Чего он намеренно не делает
- Никаких групповых кошельков в этой версии. Адрес мультиподписи — не обычный аккаунт: он вообще не может ответить на личную подпись, и дап, считающий, что может, — это дап, который тихо ломается. Подключить такой значит EIP-1271 и круг предложения через группу. Лист подключения говорит, какой аккаунт он предлагает.
- Никакого чёрного списка фишинга. Чужой список, скачиваемый на ходу, сливает каждый посещённый сайт тому, кто его держит. Что делается вместо: полный источник всегда на виду, первое посещение помечено, международные домены показаны и раскодированными, и сырыми, а обычный HTTP отвергнут наотрез.
- Пока нет ни провайдера Solana или Tron, ни вкладок. Вторая сеть — это второй скрипт и вторая таблица методов, а не переписывание; но выпустить три разом значит утроить поверхность, за которой лежат ключи. Одна страница за раз — для этой задачи полноценный браузер.
WALLETCONNECT
Те же подписи, но за ними тянутся с сайта на другом конце комнаты
WalletConnect
TRON
Solana
Состояние дел: крейт подписи и слой имён написаны и покрыты тестами; сопряжение, запросы, сессии и сквозной тест не написаны. Этот раздел — то, к чему идёт работа, и он говорит, что есть что.
Браузер dApp отвечает странице внутри приложения. WalletConnect отвечает той, что снаружи: сайту, открытому на десктопе, спаренному через считанный код. Другой транспорт, другая модель сессии — и те же четыре подписи в конце.
The same signatures, reached down a different road: a relay and a pairing code instead of a page inside the app. Which is what connects to a dapp running on a desktop.
Правило, задающее вид экрана сопряжения: сессия — это набор сетей и аккаунтов, и какие адреса раскрываются, выбирают, а не подразумевают. Сессия, которая видит все чужие сети, потому что так было проще по умолчанию, — это решение о приватности, которого никто не принимал.
Какие сети дапы на самом деле просят
Протокол не привязан к пространствам имён — сессия это набор сетей CAIP-2, а кошелёк объявляет те, которые умеет обслуживать, — так что протокол поддерживает всё. Экосистема — нет, и именно это задаёт порядок работ.
The protocol is namespace-agnostic, so it supports everything. The ecosystem does not, and the bar is that ordering rather than a measured share — doing all four at once spends the most effort on the least used.
На Tron обещание ломается, и не потому, что кошелёк не смог бы ответить: tvm с 2023 года в черновике, и в его собственном тексте сказано, что центрального реестра идентификаторов сетей нет, — так что те немногие, кто спрашивает, могут разойтись с нами в том, какую сеть имеют в виду. Делать все четыре разом значит потратить больше всего сил на самую невостребованную.
Стоит записать, потому что такое переживает ревью: у Solana идентификаторы devnet и testnet — две похожие строки base58, и по памяти они были записаны наоборот. Поймало это чтение из реестра.
Длинный шест — это подпись, а не SDK
Каждый метод подписи, который был у этого кошелька, подписывал перевод, составленный этим же приложением. Дап просит противоположную форму: произвольные байты, структурированный документ и транзакцию с произвольным calldata. Ничего из этого выразить было нельзя, поэтому любая оценка, начинающаяся с SDK, переворачивает график с ног на голову.
- Подпись сверяется с опубликованными векторами, а не с фикстурами. Правдоподобная реализация проходит дымовой тест и выдаёт подпись, которая восстанавливается в чужой адрес. Ожидаемые значения берутся из самих спецификаций и из независимой реализации той же спецификации — включая сообщение кириллицей, потому что реализация, вписывающая в префикс EIP-191 число символов, проходит все тесты на ASCII и при этом неверна.
- Выбор пакета изменился после измерения. SDK со стороны кошелька объявляет только Android и iOS, причём через платёжный модуль, который этому приложению не нужен; два пакета, несущие протокол, поддерживают и десктоп. Сборка на них — это то, что сохраняет десктопную раскладку, и это перевернуло допущение, сделанное до измерения.
- Реле видит, кто с кем спаривается. Это второе место, где данные покидают устройство незашифрованными, и ему место в настройках, написанных тем же голосом, что и первое, а не в сноске.
Четыре вещи записаны как отказы прямо сейчас, чтобы их не сделали потом втихую: никакого автоодобрения ничего, включая «это же просто подпись»; никакого раскрытия всех адресов по умолчанию; никакого молчаливого переключения сети, потому что оно меняет, в какую сеть попадёт следующая транзакция; и никакого полуразбора — честное «это вызов контракта, и этот кошелёк не может его прочитать» лучше дружелюбного пересказа того, что никто не разобрал.