Add optional flag tokenization with encrypted durable vault

This commit is contained in:
mrvasil 2026-10-23 17:49:23 +03:00
parent 45584c4dd0
commit 8dcd1feaf1
27 changed files with 1706 additions and 15 deletions

222
Cargo.lock generated
View File

@ -2,6 +2,28 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@ -198,6 +220,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.2"
@ -209,6 +242,30 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "clap"
version = "4.6.7"
@ -280,6 +337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@ -320,6 +378,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
@ -341,6 +411,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
@ -450,12 +530,30 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -686,7 +784,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
@ -730,6 +837,17 @@ version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -808,11 +926,15 @@ dependencies = [
"anyhow",
"axum",
"bytes",
"chacha20poly1305",
"clap",
"fs2",
"futures-util",
"if-addrs",
"rand 0.9.5",
"regex",
"reqwest",
"rusqlite",
"serde",
"serde_json",
"sha2",
@ -850,6 +972,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -885,6 +1013,23 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@ -1005,7 +1150,7 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"chacha20 0.10.2",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@ -1020,6 +1165,15 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
@ -1053,6 +1207,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@ -1125,6 +1291,20 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@ -1669,6 +1849,16 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@ -1705,6 +1895,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@ -1833,6 +2029,28 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"

View File

@ -10,11 +10,15 @@ license = "MIT"
anyhow = "1"
axum = { version = "0.8", features = ["json", "macros"] }
bytes = "1"
chacha20poly1305 = "0.10"
clap = { version = "4", features = ["derive"] }
fs2 = "0.4"
futures-util = "0.3"
if-addrs = "0.14"
rand = "0.9"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"

View File

@ -26,6 +26,18 @@ export MIRROR_PASSWORD
Исходный CSS перенесён с нормализацией пробелов в концах строк; Bootstrap 5.1.3 и Font Awesome Free 6.0.0 той же версии включены в бинарник. Для работы панели Интернет не нужен. Небольшие изменения: клавиатурное управление, индикаторы устаревших данных, сохранение формы во время обновлений и адаптация узкого экрана. Лицензии встроенных зависимостей доступны на `/licenses.txt` и в `web/vendor/`.
## Опциональная подмена флагов
По умолчанию выключена. После настройки `[flags]` и ключа в окружении переключатель
«Подмена флагов» в настройках сервиса включает замену входящих флагов токенами той же
длины и восстановление известных токенов в ответах. Соответствия сохраняются в
зашифрованном XChaCha20-Poly1305 кеше SQLite до отправки токена upstream. Есть TTL,
лимит записей, обработка TCP-фрагментов и статистика. Переключение действует на новые
соединения. Это токенизация с зашифрованным кешем, не авторизация клиента и не защита
от всех способов кражи. TLS/сжатые/подписанные протоколы не поддерживаются.
[Настройка, семантика и ограничения](docs/flag-protection.md).
## Доставка и перегрузка
| Режим | При медленной реплике | При недоступной/сломавшейся реплике |

View File

@ -39,3 +39,9 @@ UI follow-up completed: centered/scrollable dialogs, stable full-width selected-
## Private GitHub publication — 2026-10-23
User requested a private GitHub repository. Created `https://github.com/mrvasil/ctf-ad-mirror-tool-rust` and verified PRIVATE visibility. The existing `mrvasil/ctf-ad-mirror-tool` contains unrelated older history and was not modified. Origin points to the new repository; publish local `rewrite/rust` as remote `main`, preserving local history. Runtime config/state, dist artifacts and test-results remain ignored. Before publication: native fmt/Clippy and40 tests passed; inspected86 history blobs for common secret signatures (only synthetic localhost credential-URL rejection test matched).
## Flag protection in progress
User authorized design explanation followed by implementation. Explained random same-format tokens with XChaCha20-Poly1305 encrypted persistent mapping cache, per-service opt-in, TTL/capacity, key from environment, streaming split handling and explicit ambiguous-tail timeout. Does not distinguish checker from attacker; no TLS/compressed/signed protocol support. Original panel remains foundation. Work tracked in task_plan.md; fixtures synthetic/loopback only.
## Flag protection completed — 2026-10-23
User explicitly requires optional substitution, default off. Implemented per-service `flag_protection=false`, backwards-compatible omitted config/state fields, settings checkbox and indicator without redesign. Mode is captured for each TCP session; disabling affects new sessions. Random same-format tokens use an XChaCha20-Poly1305 encrypted SQLite vault, durable before forwarding, bounded worker queue, TTL/capacity and fail-closed errors. See docs/flag-protection.md and docs/flag-protection-verification.md. Fresh49 native/50 Orb tests, native fmt/Clippy, JS syntax and both release builds passed. Browser toggled on/off and persisted both; TCP tests verify byte integrity/half-close/replica equality and session mode. Live demo16700 upgraded with ignored persistent synthetic vault, original topology unchanged, protection off. No real hosts or flags used.

View File

@ -58,3 +58,22 @@ local across snapshots; Enter retries save and Escape restores the saved address
Changing a direct IP preserves other replicas; the backend removes any new-primary
overlap. Add-proxy drafts persist across dialog cancel/reopen, until successful save
or page reload. Process configuration remains TOML-only and requires a restart.
## Optional flag tokenization
ServiceConfig adds `flag_protection: boolean` (default false when absent). Full PUT
must preserve this field; switching it affects new connections only. Enabling
requires global `[flags]` configuration and a successfully initialized keyed vault.
Saved topology contains only this boolean, never keys or flag mappings.
Snapshot adds `flag_protection: null | {ready,entries,cache_hits,encoded,decoded,errors,capacity,ttl_seconds}`.
Counters are global across protected services and count occurrences, not distinct
flags (except entries). They reset on restart; entries reload from the encrypted
vault. Entries expire lazily on vault access/startup. `ready` means the worker is
available, not proof that every future disk write will succeed. Flags, tokens,
keys, and vault paths are never returned. No endpoint dumps or clears the vault.
Known tokens are decoded only in the matching service-ID namespace. Unknown/expired
matches pass unchanged. Full-vault or persistence failure closes the protected TCP
session and increments errors; there is no plaintext fallback. See
[flag-protection.md](flag-protection.md) for format, TTL, cryptography and TCP limits.

View File

@ -0,0 +1,15 @@
# Flag tokenization verification — 2026-10-23
Final source uses SQLite synchronous=EXTRA. Fresh root checks:
- macOS arm64: cargo fmt --check; cargo clippy --all-targets --locked -- -D warnings; cargo test --all-targets --locked — 49 passed; cargo build --release --locked; node --check web/app.js.
- Orb Linux amd64: cargo test --all-targets --locked — 50 passed, including Linux loopback behavior; cargo build --release --locked.
- Updated both ignored dist binaries and verified SHA256SUMS.
Eight flag integration tests plus the vault expiry unit test cover omitted opt-in=false, missing vault rejection, format validation, every two-way split, binary integrity, unknown output, EOF, partial timeout, concurrent reuse, encrypted persistence/restart, wrong key/damage, per-service isolation, capacity and disk failure, identical primary/replica tokens, restored reply, per-connection toggle and HTTP length preservation. Existing forwarding and control tests also pass.
Chrome isolated panel16900: settings checkbox initially off; enable/save shows indicator, reopening shows checked and disk state is true. Synthetic TCP echo returns exact original bytes. Disable/save removes indicator, disk state is false. No visual redesign. Screenshot from this feature pass is locally ignored at test-results/flags-toggle-off.png.
Persistent demo16700 now runs the final native release with a local synthetic vault configured. Settings shows enabled checkbox control with value false. Pre/post upgrade topology compares identical. Demo18081 and isolated18281 each return all32802 test bytes exactly after TCP half-close while protection is off. Both release hashes verify. Isolated fixture is stopped after verification; live demo remains.
No load/soak test, real tournament hosts or real flags used. No claim of universal protocol transparency: candidate tails can wait/timeout, TLS/compression/signatures need adapters, TTL/key loss affects restoration, and all clients receive reverse translation. New flag mappings incur durable-write latency; disabled sessions bypass transformation.

138
docs/flag-protection.md Normal file
View File

@ -0,0 +1,138 @@
# Опциональная подмена флагов
Режим **выключен по умолчанию**, отдельно включается у каждого сервиса в его
настройках. Без него прокси передаёт байты обычным путём. Переключатель влияет
только на новые TCP-соединения; открытые сохраняют свой режим до завершения.
Существующие TOML/state-файлы совместимы: отсутствующее `flag_protection` равно false.
## Как работает
Видимый входящий флаг заменяется случайным токеном **той же длины и формы**.
Повторный флаг внутри одного сервиса получает тот же токен до истечения срока
записи. Сервисы разделены по неизменяемому ID. Подмена выполняется один раз до
отправки primary и репликам. В ответах primary восстанавливаются только токены,
которые есть в кеше этого сервиса. Неизвестные совпадения передаются без изменения.
Это **токенизация**, а не самодельное шифрование с сохранением длины. Соответствия
зашифрованы XChaCha20-Poly1305 с независимым случайным 192-битным nonce на запись
(RustCrypto). SQLite хранит зашифрованные записи и служебные данные (схема, срок
истечения, число записей), но не открытые флаги/токены. Ключ — 256 бит из окружения.
В рабочей памяти соответствия доступны процессу; флаги и ключ не выдаются API,
не выводятся в панель/логи и не записываются в обычный topology state.
Новое соответствие подтверждается SQLite transaction (`synchronous=EXTRA`, включая синхронизацию каталога журнала) **до**
отправки токена в сеть. Повторное обращение не требует записи. Один фоновый worker
владеет БД и индексами; ограниченная очередь принимает только найденные совпадения.
Сетевые задачи асинхронно ждут результат без удержания сетевых блокировок. Если
клиент исчез после приёма задания worker, начатая запись всё равно завершается.
Это добавляет задержку при первом появлении уникального флага; режим не обещает
скорость обычной пересылки. Не включайте его без проверки конкретного протокола.
## Настройка
Создайте ключ **один раз** и храните его отдельно от резервной копии базы. Например,
в своей защищённой директории (не в Git):
```sh
umask 077
mkdir -p data
# Выполнять только для новой пустой базы; не перезаписывать существующий ключ.
( set -C; openssl rand -hex 32 > data/flag-key.hex )
export MIRROR_FLAG_KEY="$(cat data/flag-key.hex)"
```
Добавьте секцию в TOML:
```toml
[flags]
database = "data/flags.sqlite3"
key_env = "MIRROR_FLAG_KEY"
prefix = ""
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
payload_length = 31
suffix = "="
ttl_seconds = 86400
max_entries = 10000
partial_timeout_ms = 5000
```
Этот пример распознаёт `[A-Z0-9]{31}=`. Формат задаётся как буквальный prefix,
фиксированное число символов из alphabet и буквальный suffix. Это не произвольный
regex. Например, для `FLAG{` + 32 hex-символа + `}` задайте prefix `FLAG{`, alphabet
`0123456789ABCDEF`, payload_length 32 и suffix `}`. Регистр важен. Совпадения ищутся
как подстроки, слева направо, без перекрытий; границы слов не подразумеваются.
`payload_length`: 16–128, alphabet: 16–94 уникальных печатных ASCII-символа;
prefix/suffix: до 32 печатных ASCII-символов. Ёмкость: 1–100000 записей,
TTL: 60–604800 секунд, тайм-аут фрагмента: 100–60000 мс.
Путь БД отсчитывается от каталога TOML. Файлы БД и её lock имеют права0600 на Unix;
одна БД может обслуживаться только одним процессом Mirror Tool.
После настройки перезапустите процесс с ключом в окружении. Затем кликните
название сервиса → **«Подмена флагов»** → «Сохранить». При отсутствии готового кеша
переключатель недоступен, показано объяснение. Включённый режим обозначен замком
рядом с названием; сводные счётчики кеша доступны в справке ⓘ.
Для стартовой топологии можно указать `flag_protection = true` в нужном
`[[services]]`, но существующий `state.json` имеет приоритет над стартовой
топологией TOML. Обычный CLI `check` проверяет структуру конфигурации, но не
открывает БД и не проверяет ключ; проверка ключа выполняется при запуске.
## Срок хранения и сбои
- TTL отсчитывается от первого сохранения; повторный флаг не продлевает его.
Настройте его **дольше срока жизни флага плюс задержка хранения/выдачи ответа**.
- Истёкшие записи удаляются при следующем запросе к кешу либо запуске. Счётчик
entries отражает хранимые записи до этой очистки. После истечения токен неизвестен
и не восстанавливается. Изменение TTL относится к новым записям.
- Нет вытеснения живых записей ради новых. При переполнении, ошибке записи или
недоступности worker защищённая сессия завершается с ошибкой; исходный флаг
не отправляется вместо токена. Уже отправленная часть другого содержимого
не откатывается. Обычные сервисы продолжают работать независимо.
- Неверный ключ, изменение формата для существующей БД, повреждённые записи либо
превышение новой ёмкости запрещают запуск вместо молчаливого сброса кеша.
- Для восстановления нужны **и база, и прежний ключ**. Ротация ключа/формата с
миграцией живых записей не реализована. Не удаляйте БД или ключ во время игры.
Резервную копию SQLite делайте при остановленном процессе или средствами SQLite.
- Аутентификация записей обнаруживает повреждение/неверный ключ, но не защищает
от удаления всей базы или подстановки её старой копии локальным злоумышленником.
## TCP и ограничения протокола
Обработчик распознаёт флаги через любые границы TCP-чтений. Удерживается только
возможное начало незаконченного совпадения — максимум длина флага минус один байт.
Остальные байты передаются дальше. При EOF незавершённый фрагмент уже не может стать
флагом и отправляется как обычный текст. Если продолжения/EOF нет до тайм-аута,
соединение закрывается: выпуск такого фрагмента по таймеру мог бы пропустить флаг.
TCP не содержит границ сообщений. Если запрос заканчивается возможным началом
флага и приложение ждёт ответа, может потребоваться другой формат или адаптер
протокола; универсально разрешить такую неоднозначность нельзя. Пустые prefix/suffix
повышают вероятность случайных совпадений. Используйте точный формат игры.
Режим работает только с флагами, видимыми в исходных байтах. Нет декодирования
TLS, gzip, URL/base64/JSON-escape и прочих представлений. Сохранение длины оставляет
корректными длины простых сообщений, но не подписи, checksums или семантику
аутентификации. Побочные эффекты зеркалируемых серверов остаются отдельной проблемой.
Подмена уже хранящихся данных задним числом не выполняется.
**Это не фильтр атакующих:** известные токены восстанавливаются всем клиентам.
Кража через уязвимый сервис, доступный через этот же прокси, может вернуть оригинал.
Режим скрывает оригиналы от upstream, но не заменяет исправление уязвимости.
## Разработка
`src/flags/mod.rs`: настройки/формат; `stream.rs`: обработка TCP-фрагментов;
`vault.rs`: криптография, SQLite, actor, TTL и статистика. Сетевой engine держит
настройку в снимке маршрута на время сессии. Проверки находятся в
`tests/flag_protection.rs` и unit-тесте expiry внутри vault.
Изолированный UI fixture с новым тестовым ключом и **выключенной подменой**:
```sh
python3 scripts/ui_fixture.py target/release/mirror-tool \
--data-dir test-results/flags-ui --port-offset 200 --flag-vault
```
Fixture создаёт локальный файл ключа0600; повторно использует его после рестарта.
Он предназначен только для локального демо, не для настоящей игры.

View File

@ -133,6 +133,7 @@ async fn main() -> Result<()> {
},
enabled: true,
auto_switch: false,
flag_protection: false,
replica_policy: if mode == "strict" {
ReplicaPolicy::Strict
} else {

View File

@ -32,3 +32,9 @@
- Browser Use fill does not trigger a native dirty-field change on blur in this environment; direct-IP behavior was verified with keyboard typing and Tab instead. Browser-wide viewport override was ineffective; tab-scoped CDP metrics produced actual390x844 and was cleared after verification.
- R6 resolved by one owned admitted transaction; cancelled waiters allocate no worker. Five cancellation regressions plus independent actual HTTP red/green prove cleanup/publication/rollback convergence. Final review approved, no remaining Critical/Important.
2026-10-23 flag protection: engine currently sends client bytes unchanged before replica fanout and primary bytes unchanged on return. ServiceConfig strict fields require adding defaulted opt-in and updating UI full-PUT serializer. A same-length authenticated ciphertext cannot generally fit original flag domain; use random format-preserving tokens plus encrypted durable map. Mapping must commit before upstream writes. SQLite encrypted records give bounded incremental durable writes; a bounded actor owns DB/maps, avoiding locks across network I/O. Generic TCP has no message boundaries: partial matching tails require bounded timeout and documented protocol limitation.
Flag implementation: first6 synthetic tests passed (all byte splits, restart/wrong-key/tamper, capacity/SQL write failure,24 concurrent repeats, TCP primary/replica byte identity, opt-out/new-vs-existing sessions, incomplete-tail timeout). Replaced naive per-position matching with precompiled byte regex to avoid repeated full-format checks over each input buffer. Actor serializes only detected matches, no lock is held across network I/O. Existing data path remains buffer reuse when disabled.
Final durability review: use SQLite rollback-journal synchronous=EXTRA, not FULL, so journal deletion also syncs its directory before acknowledging a new mapping. No live entries evicted; expired mappings intentionally become unknown and pass through, documented. Global format is fixed per vault, changing it with existing DB fails startup. Worker status/counters never include original/token/key/path. User explicitly reiterated per-service opt-in, default false; browser verified initial off, on round-trip, persisted off and cache bypass after disabling.

View File

@ -55,3 +55,18 @@ auto_switch = false
# isolate: primary stays fast; explicitly disconnect a slow/failed replica.
# strict: backpressure to deliver all bytes to each healthy replica.
replica_policy = "isolate"
# Optional flag tokenization: disabled for every service unless explicitly enabled.
# Configure a persistent key in MIRROR_FLAG_KEY before uncommenting this section.
# Then set flag_protection = true in a service or enable it in the UI.
# See docs/flag-protection.md for protocol/TTL limitations.
# [flags]
# database = "data/flags.sqlite3"
# key_env = "MIRROR_FLAG_KEY"
# prefix = ""
# alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
# payload_length = 31
# suffix = "="
# ttl_seconds = 86400
# max_entries = 10000
# partial_timeout_ms = 5000

View File

@ -17,3 +17,7 @@
2026-10-23: R6 resolved with admitted owned transaction worker shared by service/team/automatic changes. Five cancellation regressions, original HTTP red/green and independent re-review accepted. Root fresh final gates: native40/Orb41 tests, native fmt/Clippy, both release builds, ForcAD library interoperability and sample config/JS checks all passed. Final release UI start/primary/replica/robot/custom creation and exact1MiB half-close rechecked. Six final benchmark records committed; isolate disconnections3macOS/4Linux documented explicitly. Prepared git-ignored dist binaries/checksums; no deployment.
2026-10-23 UI follow-up: reproduced off-center modal at(0,0), repaired Bootstrap/native-dialog interaction and header wrapping without redesign. Added local draft/input improvements, team add/rename and configuration help. Fresh40 native/41 Orb tests and release builds pass; browser validates390px layout, CRUD, keyboard invalid IP, restart persistence, stale/recovery and exact256KiB half-close with replica integrity. Live demo migrated from temporary state to ignored data/demo, preserving primaryB/replicaA/enabled; readiness/assets and21504-byte echo verified after restart. See docs/ui-polish-verification.md. Test fixture stopped, live GUI-session job remains running.
2026-10-23 flag feature: user selected universal configurable mode. Design explained before implementation; use same-format random tokenization plus authenticated encrypted durable SQLite records (not homemade same-length encryption). Initial cargo-add multiple-dependency feature syntax rejected; retried separate dependency additions successfully. No existing source behavior changed yet.
2026-10-23 flag completion: per-service opt-in defaults off for existing/new configurations; UI switch and indicator preserve the legacy page. Encrypted durable cache, split-safe transformations and documented protocol limits complete. Final native49/Orb50 tests, fmt/Clippy/JS and releases passed. Browser verified on/off persistence; loopback round-trips, replicas, half-close, errors and restart covered. Demo16700 updated with original topology and protection off. Orb lacked rsync, so staging used cp; launchctl bootstrap rejected relative plist path, succeeded with the absolute path after validating plist and absent job. See docs/flag-protection-verification.md.

View File

@ -4,6 +4,7 @@
Usage: python3 scripts/ui_fixture.py target/debug/mirror-tool
Public test-only login: local-ui-fixture-only. Default state is disposable.
Use --data-dir data/demo for persistent state; --port-offset 100 isolates a test.
--flag-vault prepares an encrypted synthetic vault; services still default off.
"""
import argparse
import asyncio
@ -32,6 +33,7 @@ async def echo(reader, writer):
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("executable", type=Path)
parser.add_argument("--flag-vault", action="store_true", help="Configure a synthetic flag vault (service protection remains off)")
parser.add_argument("--data-dir", type=Path, help="Preserve config/state here; never overwrite an existing config")
parser.add_argument("--port-offset", type=int, default=0, help="Add to all fixture ports (0..30000)")
args = parser.parse_args()
@ -45,7 +47,8 @@ async def main():
with context as folder:
root = Path(folder)
config = root / "mirror.toml"
if not config.exists():
new_config = not config.exists()
if new_config:
config.write_text(f'''[web]
listen = "127.0.0.1:{ui_port}"
state_file = "state.json"
@ -70,9 +73,24 @@ enabled = false
auto_switch = false
replica_policy = "isolate"
''')
if args.flag_vault:
if new_config:
with config.open("a") as file:
file.write('\n[flags]\ndatabase = "flags.sqlite3"\n')
elif "[flags]" not in config.read_text():
parser.error("existing config has no [flags]; add database = \"flags.sqlite3\" under [flags] first")
key_file = root / "flag-key.hex"
if not key_file.exists():
if (root / "flags.sqlite3").exists():
parser.error("existing vault has no key file; restore its original key")
fd = os.open(key_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as file:
file.write(os.urandom(32).hex())
servers = [await asyncio.start_server(echo, address, echo_port) for address in ("127.0.0.1", "::1")]
env = os.environ.copy()
env["MIRROR_PASSWORD"] = "local-ui-fixture-only"
if args.flag_vault:
env["MIRROR_FLAG_KEY"] = key_file.read_text().strip()
process = await asyncio.create_subprocess_exec(executable, "--config", str(config), env=env)
stop = asyncio.Event()
for sig in (signal.SIGINT, signal.SIGTERM):

View File

@ -10,6 +10,7 @@ use std::{
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub main_team_ip: Option<IpAddr>,
pub flags: Option<crate::flags::FlagConfig>,
pub web: WebConfig,
pub network: NetworkConfig,
pub monitoring: MonitoringConfig,
@ -99,6 +100,8 @@ pub struct ServiceConfig {
pub auto_switch: bool,
#[serde(default)]
pub replica_policy: ReplicaPolicy,
#[serde(default)]
pub flag_protection: bool,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
@ -172,6 +175,18 @@ impl Config {
.unwrap_or(Path::new("."))
.join(&config.web.state_file);
}
if let Some(flags) = &mut config.flags {
if flags.database.is_relative() {
flags.database = path
.parent()
.unwrap_or(Path::new("."))
.join(&flags.database);
}
ensure!(
flags.database != path,
"flag database cannot be the TOML config"
);
}
config.validate()?;
Ok(config)
}
@ -236,6 +251,17 @@ impl Config {
"invalid scoreboard intervals"
);
}
if let Some(flags) = &self.flags {
flags.validate()?;
ensure!(
flags.database != self.web.state_file,
"flag database and topology state must be separate files"
);
}
ensure!(
self.flags.is_some() || !self.services.iter().any(|s| s.flag_protection),
"configure [flags] and its key before enabling flag protection"
);
validate_topology(&self.teams, &self.services)?;
for service in &self.services {
ensure!(

View File

@ -50,6 +50,7 @@ pub struct Snapshot {
pub teams: Vec<Team>,
pub services: Vec<ServiceView>,
pub scoreboard: BoardSnapshot,
pub flag_protection: Option<crate::flags::FlagStatus>,
pub events: Vec<Event>,
}
@ -76,8 +77,17 @@ pub struct Controller {
impl Controller {
pub async fn start(config: Config) -> Result<Arc<Self>> {
let topology = Topology::load(&config)?;
let vault = if let Some(settings) = config.flags.clone() {
Some(
tokio::task::spawn_blocking(move || crate::flags::Vault::from_env(settings))
.await
.context("initialize flag vault worker")??,
)
} else {
None
};
let this = Arc::new_cyclic(|weak| Self {
engine: Engine::new(config.network.clone()),
engine: Engine::with_flag_vault(config.network.clone(), vault.clone()),
board: Scoreboard::new(config.scoreboard.as_ref()),
topology: RwLock::new(topology.clone()),
operations: Arc::new(Mutex::new(())),
@ -164,6 +174,7 @@ impl Controller {
teams: topology.teams,
services,
scoreboard: board,
flag_protection: self.engine.flag_status(),
events: self.events.read().unwrap().iter().cloned().collect(),
}
}
@ -569,6 +580,7 @@ mod tests {
replicas: vec!["b".into()],
enabled: true,
auto_switch: true,
flag_protection: false,
replica_policy: ReplicaPolicy::Isolate,
};
let health = teams
@ -731,6 +743,7 @@ mod cancellation_tests {
replicas: vec![],
enabled: true,
auto_switch: false,
flag_protection: false,
replica_policy: ReplicaPolicy::Isolate,
custom: true,
custom_ip: Some("127.0.0.1".parse().unwrap()),

View File

@ -10,6 +10,7 @@ use std::{
};
use crate::config::{NetworkConfig, ReplicaPolicy, ServiceConfig, Team};
use crate::flags::{FlagStatus, Transformer, Vault};
use anyhow::{Context, Result, anyhow, ensure};
use bytes::Bytes;
use serde::Serialize;
@ -87,6 +88,7 @@ struct Route {
primary: SocketAddr,
replicas: Vec<SocketAddr>,
policy: ReplicaPolicy,
protection: Option<(Arc<Vault>, String)>,
}
struct Generation {
stop: CancellationToken,
@ -104,6 +106,7 @@ struct Service {
}
struct Inner {
settings: NetworkConfig,
vault: Option<Arc<Vault>>,
services: Mutex<BTreeMap<String, Arc<Service>>>,
operations: tokio::sync::Mutex<()>,
permits: Arc<Semaphore>,
@ -117,11 +120,15 @@ pub struct Engine {
}
impl Engine {
pub fn new(settings: NetworkConfig) -> Self {
Self::with_flag_vault(settings, None)
}
pub fn with_flag_vault(settings: NetworkConfig, vault: Option<Arc<Vault>>) -> Self {
Self {
inner: Arc::new(Inner {
permits: Arc::new(Semaphore::new(settings.max_connections.max(1))),
payload_budget: Arc::new(Semaphore::new(settings.max_buffered_bytes)),
settings,
vault,
services: Mutex::new(BTreeMap::new()),
operations: tokio::sync::Mutex::new(()),
stopped: AtomicBool::new(false),
@ -129,6 +136,9 @@ impl Engine {
}),
}
}
pub fn flag_status(&self) -> Option<FlagStatus> {
self.inner.vault.as_ref().map(|v| v.status())
}
pub fn snapshots(&self) -> BTreeMap<String, EngineStatus> {
self.inner
.services
@ -150,6 +160,10 @@ impl Engine {
pub async fn upsert(&self, service: &ServiceConfig, teams: &[Team]) -> Result<()> {
let _operation = self.inner.operations.lock().await;
ensure!(!self.inner.stopped.load(Relaxed), "engine is shut down");
ensure!(
!service.flag_protection || self.inner.vault.is_some(),
"flag protection requires a configured vault"
);
let route = if service.enabled {
let address = |id: &str| -> Result<SocketAddr> {
Ok(SocketAddr::new(
@ -174,6 +188,11 @@ impl Engine {
.map(|id| address(id))
.collect::<Result<_>>()?,
policy: service.replica_policy,
protection: if service.flag_protection {
self.inner.vault.clone().map(|v| (v, service.id.clone()))
} else {
None
},
})
} else {
None
@ -573,19 +592,46 @@ async fn forward(
}
let (mut client_read, mut client_write) = client.into_split();
let (mut primary_read, mut primary_write) = primary.into_split();
let mut encoder = route
.protection
.as_ref()
.map(|(vault, id)| Transformer::new(vault.clone(), id.clone(), true));
let mut decoder = route
.protection
.as_ref()
.map(|(vault, id)| Transformer::new(vault.clone(), id.clone(), false));
let transfer = async {
let up = async {
let mut buffer = vec![0; settings.buffer_size.max(1)];
loop {
let n = client_read.read(&mut buffer).await?;
let n = match &mut encoder {
Some(codec) => codec.read(&mut client_read, &mut buffer).await?,
None => client_read.read(&mut buffer).await?,
};
let eof = n == 0;
let transformed;
let data = if let Some(codec) = &mut encoder {
transformed = codec.feed(&buffer[..n], eof).await?;
transformed.as_slice()
} else {
&buffer[..n]
};
let n = data.len();
if n == 0 {
primary_write.shutdown().await?;
break;
if eof {
primary_write.shutdown().await?;
break;
}
continue;
}
// The common unmirrored path reuses its buffer without a per-read
// allocation/copy. Allocate shared chunks only while replicas exist.
if sinks.is_empty() {
write_counted(&mut primary_write, &buffer[..n], &counters.bytes_up).await?;
write_counted(&mut primary_write, data, &counters.bytes_up).await?;
if eof {
primary_write.shutdown().await?;
break;
}
continue;
}
let permit = if route.policy == ReplicaPolicy::Strict {
@ -608,11 +654,15 @@ async fn forward(
}
sink.cancel.cancel();
}
write_counted(&mut primary_write, &buffer[..n], &counters.bytes_up).await?;
write_counted(&mut primary_write, data, &counters.bytes_up).await?;
if eof {
primary_write.shutdown().await?;
break;
}
continue;
};
let bytes = Bytes::from_owner(BudgetedChunk {
bytes: buffer[..n].to_vec(),
bytes: data.to_vec(),
_permit: permit,
});
let mut i = 0;
@ -637,6 +687,10 @@ async fn forward(
}
}
write_counted(&mut primary_write, &bytes, &counters.bytes_up).await?;
if eof {
primary_write.shutdown().await?;
break;
}
}
sinks.clear();
Ok::<_, std::io::Error>(())
@ -644,12 +698,20 @@ async fn forward(
let down = async {
let mut buffer = vec![0; settings.buffer_size.max(1)];
loop {
let n = primary_read.read(&mut buffer).await?;
let n = match &mut decoder {
Some(codec) => codec.read(&mut primary_read, &mut buffer).await?,
None => primary_read.read(&mut buffer).await?,
};
if let Some(codec) = &mut decoder {
let data = codec.feed(&buffer[..n], n == 0).await?;
write_counted(&mut client_write, &data, &counters.bytes_down).await?;
} else {
write_counted(&mut client_write, &buffer[..n], &counters.bytes_down).await?;
}
if n == 0 {
client_write.shutdown().await?;
break;
}
write_counted(&mut client_write, &buffer[..n], &counters.bytes_down).await?;
}
Ok::<_, std::io::Error>(())
};
@ -777,6 +839,7 @@ mod accept_tests {
primary: upstream,
replicas: vec![],
policy: ReplicaPolicy::Isolate,
protection: None,
}))),
counters: Arc::new(Counters::default()),
error: Arc::new(Mutex::new(None)),

111
src/flags/mod.rs Normal file
View File

@ -0,0 +1,111 @@
//! Optional format-preserving tokenization with an authenticated encrypted vault.
//! This is not client authorization or encryption of the transport itself.
mod stream;
mod vault;
use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, path::PathBuf};
pub use stream::Transformer;
pub use vault::{FlagStatus, Vault};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FlagConfig {
pub database: PathBuf,
pub key_env: String,
pub prefix: String,
pub alphabet: String,
pub payload_length: usize,
pub suffix: String,
pub ttl_seconds: u64,
pub max_entries: usize,
pub partial_timeout_ms: u64,
}
impl Default for FlagConfig {
fn default() -> Self {
Self {
database: "data/flags.sqlite3".into(),
key_env: "MIRROR_FLAG_KEY".into(),
prefix: String::new(),
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".into(),
payload_length: 31,
suffix: "=".into(),
ttl_seconds: 86400,
max_entries: 10000,
partial_timeout_ms: 5000,
}
}
}
impl FlagConfig {
pub fn validate(&self) -> Result<()> {
ensure!(
!self.database.as_os_str().is_empty(),
"flags.database is required"
);
ensure!(
!self.key_env.is_empty()
&& self
.key_env
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_'),
"invalid flags.key_env"
);
ensure!(
(16..=128).contains(&self.payload_length),
"flags.payload_length must be 16..128"
);
let alphabet: HashSet<_> = self.alphabet.bytes().collect();
ensure!(
(16..=94).contains(&alphabet.len())
&& alphabet.len() == self.alphabet.len()
&& self.alphabet.bytes().all(|c| c.is_ascii_graphic()),
"flags.alphabet must contain 16..94 distinct printable ASCII bytes"
);
ensure!(
self.prefix.len() <= 32
&& self.suffix.len() <= 32
&& self
.prefix
.bytes()
.chain(self.suffix.bytes())
.all(|c| c.is_ascii_graphic()),
"flag prefix/suffix must be printable ASCII, at most 32 bytes each"
);
ensure!(
(60..=604800).contains(&self.ttl_seconds),
"flags.ttl_seconds must be 60..604800"
);
ensure!(
(1..=100000).contains(&self.max_entries),
"flags.max_entries must be 1..100000"
);
ensure!(
(100..=60000).contains(&self.partial_timeout_ms),
"flags.partial_timeout_ms must be 100..60000"
);
Ok(())
}
pub fn width(&self) -> usize {
self.prefix.len() + self.payload_length + self.suffix.len()
}
pub fn matches_prefix(&self, bytes: &[u8]) -> bool {
bytes.len() <= self.width()
&& bytes.iter().enumerate().all(|(i, &c)| {
if i < self.prefix.len() {
c == self.prefix.as_bytes()[i]
} else if i < self.prefix.len() + self.payload_length {
self.alphabet.as_bytes().contains(&c)
} else {
c == self.suffix.as_bytes()[i - self.prefix.len() - self.payload_length]
}
})
}
pub(crate) fn format_id(&self) -> Vec<u8> {
serde_json::to_vec(&(
&self.prefix,
&self.alphabet,
self.payload_length,
&self.suffix,
))
.expect("format serialization")
}
}

84
src/flags/stream.rs Normal file
View File

@ -0,0 +1,84 @@
use super::Vault;
use std::{io, sync::Arc};
use tokio::{
io::{AsyncRead, AsyncReadExt},
time::{Duration, Instant, timeout_at},
};
/// Retain only a suffix which can still become a complete flag. Read boundaries
/// are not messages. EOF proves a suffix incomplete; timeout must fail closed.
pub struct Transformer {
vault: Arc<Vault>,
service: String,
encode: bool,
pending: Vec<u8>,
deadline: Option<Instant>,
}
impl Transformer {
pub fn new(vault: Arc<Vault>, service: String, encode: bool) -> Self {
Self {
vault,
service,
encode,
pending: Vec::new(),
deadline: None,
}
}
pub async fn read<R: AsyncRead + Unpin>(
&mut self,
reader: &mut R,
buffer: &mut [u8],
) -> io::Result<usize> {
if let Some(deadline) = self.deadline {
timeout_at(deadline, reader.read(buffer)).await.map_err(|_| {
self.vault.record_error();
io::Error::new(io::ErrorKind::TimedOut, "incomplete flag candidate timed out; protocol needs framing or a different flag format")
})?
} else {
reader.read(buffer).await
}
}
pub async fn feed(&mut self, data: &[u8], eof: bool) -> io::Result<Vec<u8>> {
let mut input = std::mem::take(&mut self.pending);
input.extend_from_slice(data);
let cfg = &self.vault.config;
let width = cfg.width();
let mut matches = Vec::new();
let mut positions = Vec::new();
let mut pos = 0;
for found in self.vault.matcher.find_iter(&input) {
positions.push(found.start());
matches.push(found.as_bytes().to_vec());
pos = found.end();
}
// A future match can start only within the last width-1 bytes and after
// the last complete match. Regex handles the full buffer in linear time.
pos = pos.max(input.len().saturating_sub(width - 1));
let mut emit = input.len();
if !eof {
while pos < input.len() {
if cfg.matches_prefix(&input[pos..]) {
emit = pos;
break;
}
pos += 1;
}
}
if !matches.is_empty() {
let translated = self
.vault
.translate(&self.service, matches, self.encode)
.await
.map_err(|e| io::Error::other(e.to_string()))?;
for (position, replacement) in positions.into_iter().zip(translated) {
input[position..position + width].copy_from_slice(&replacement);
}
}
self.pending = input.split_off(emit);
if self.pending.is_empty() {
self.deadline = None;
} else if emit > 0 || self.deadline.is_none() {
self.deadline = Some(Instant::now() + Duration::from_millis(cfg.partial_timeout_ms));
}
Ok(input)
}
}

489
src/flags/vault.rs Normal file
View File

@ -0,0 +1,489 @@
use super::FlagConfig;
use anyhow::{Context, Result, anyhow, bail, ensure};
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, Payload},
};
use fs2::FileExt;
use rand::Rng;
use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeSet, HashMap, HashSet},
fs::{File, OpenOptions},
path::Path,
sync::{
Arc,
atomic::{AtomicU64, Ordering::Relaxed},
},
time::{SystemTime, UNIX_EPOCH},
};
use tokio::sync::{mpsc, oneshot};
const AAD: &[u8] = b"mirror-tool/flag-vault/v1";
type Key = (String, Vec<u8>);
#[derive(Default)]
struct Counters {
entries: AtomicU64,
hits: AtomicU64,
encoded: AtomicU64,
decoded: AtomicU64,
errors: AtomicU64,
}
#[derive(Clone, Debug, Serialize)]
pub struct FlagStatus {
pub ready: bool,
pub entries: u64,
pub cache_hits: u64,
pub encoded: u64,
pub decoded: u64,
pub errors: u64,
pub capacity: usize,
pub ttl_seconds: u64,
}
struct Request {
service: String,
values: Vec<Vec<u8>>,
encode: bool,
reply: oneshot::Sender<Result<Vec<Vec<u8>>>>,
}
pub struct Vault {
pub config: FlagConfig,
pub(crate) matcher: regex::bytes::Regex,
sender: mpsc::Sender<Request>,
counters: Arc<Counters>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Entry {
service: String,
original: Vec<u8>,
token: Vec<u8>,
expires: u64,
}
struct Worker {
connection: Connection,
_lock: File,
cipher: XChaCha20Poly1305,
config: FlagConfig,
entries: HashMap<Key, Entry>,
reverse: HashMap<Key, Key>,
expiry: BTreeSet<(u64, Key)>,
counters: Arc<Counters>,
}
fn now() -> Result<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("flag vault clock")?
.as_secs())
}
fn private_file(path: &Path) -> Result<File> {
ensure!(
!path
.symlink_metadata()
.is_ok_and(|m| m.file_type().is_symlink()),
"flag vault paths cannot be symlinks"
);
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path).context("open private flag vault file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(file)
}
fn seal(cipher: &XChaCha20Poly1305, value: &[u8]) -> Result<Vec<u8>> {
let nonce = rand::random::<[u8; 24]>();
let encrypted = cipher
.encrypt(
XNonce::from_slice(&nonce),
Payload {
msg: value,
aad: AAD,
},
)
.map_err(|_| anyhow!("flag vault encryption failed"))?;
Ok([nonce.as_slice(), &encrypted].concat())
}
fn open(cipher: &XChaCha20Poly1305, value: &[u8]) -> Result<Vec<u8>> {
ensure!(
value.len() >= 40 && value.len() <= 4096,
"invalid encrypted flag record size"
);
cipher
.decrypt(
XNonce::from_slice(&value[..24]),
Payload {
msg: &value[24..],
aad: AAD,
},
)
.map_err(|_| anyhow!("flag vault authentication failed: wrong key or damaged database"))
}
impl Vault {
/// Initialize on a blocking worker; environment key is exactly 64 hex digits.
pub fn from_env(config: FlagConfig) -> Result<Arc<Self>> {
let value = std::env::var(&config.key_env)
.context("set the configured flag key environment variable to 64 hex digits")?;
ensure!(
value.len() == 64 && value.bytes().all(|c| c.is_ascii_hexdigit()),
"flag key must contain exactly 64 hex digits"
);
let mut key = [0u8; 32];
for (i, byte) in key.iter_mut().enumerate() {
*byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16)
.map_err(|_| anyhow!("invalid flag key"))?;
}
Self::with_key(config, key)
}
/// Explicit key injection is also used by synthetic isolated tests.
pub fn with_key(config: FlagConfig, key: [u8; 32]) -> Result<Arc<Self>> {
config.validate()?;
let pattern = format!(
"(?-u:{}[{}]{{{}}}{})",
regex::escape(&config.prefix),
regex::escape(&config.alphabet),
config.payload_length,
regex::escape(&config.suffix)
);
let matcher = regex::bytes::Regex::new(&pattern).context("compile flag format")?;
let counters = Arc::new(Counters::default());
let worker = Worker::open(config.clone(), key, counters.clone())?;
let (sender, mut receiver) = mpsc::channel::<Request>(32);
std::thread::Builder::new()
.name("mirror-flag-vault".into())
.spawn(move || {
let mut worker = worker;
while let Some(request) = receiver.blocking_recv() {
// Once admitted, commit/publication finishes even if the TCP caller disappears.
let result = worker.translate(&request.service, request.values, request.encode);
if result.is_err() {
worker.counters.errors.fetch_add(1, Relaxed);
}
let _ = request.reply.send(result);
}
})
.context("start flag vault worker")?;
Ok(Arc::new(Self {
config,
matcher,
sender,
counters,
}))
}
pub fn status(&self) -> FlagStatus {
FlagStatus {
ready: !self.sender.is_closed(),
entries: self.counters.entries.load(Relaxed),
cache_hits: self.counters.hits.load(Relaxed),
encoded: self.counters.encoded.load(Relaxed),
decoded: self.counters.decoded.load(Relaxed),
errors: self.counters.errors.load(Relaxed),
capacity: self.config.max_entries,
ttl_seconds: self.config.ttl_seconds,
}
}
pub fn record_error(&self) {
self.counters.errors.fetch_add(1, Relaxed);
}
pub async fn translate(
&self,
service: &str,
values: Vec<Vec<u8>>,
encode: bool,
) -> Result<Vec<Vec<u8>>> {
ensure!(
crate::config::valid_id(service),
"invalid flag service namespace"
);
ensure!(
values.len() <= 65536
&& values
.iter()
.all(|v| v.len() == self.config.width() && self.config.matches_prefix(v)),
"invalid flag translation batch"
);
let (reply, receive) = oneshot::channel();
self.sender
.send(Request {
service: service.into(),
values,
encode,
reply,
})
.await
.map_err(|_| anyhow!("flag vault worker unavailable"))?;
receive.await.context("flag vault worker stopped")?
}
}
impl Worker {
fn open(config: FlagConfig, key: [u8; 32], counters: Arc<Counters>) -> Result<Self> {
let parent = config
.database
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
std::fs::create_dir_all(parent).context("create flag vault directory")?;
let mut lock_path = config.database.as_os_str().to_os_string();
lock_path.push(".lock");
let lock = private_file(Path::new(&lock_path))?;
lock.try_lock_exclusive()
.context("flag vault is already in use")?;
drop(private_file(&config.database)?);
let connection = Connection::open(&config.database).context("open flag database")?;
connection.busy_timeout(std::time::Duration::from_secs(2))?;
connection.execute_batch("PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA secure_delete=ON; PRAGMA auto_vacuum=FULL; CREATE TABLE IF NOT EXISTS flag_meta (id INTEGER PRIMARY KEY CHECK(id=1), sealed BLOB NOT NULL); CREATE TABLE IF NOT EXISTS flag_entries (id INTEGER PRIMARY KEY, expires INTEGER NOT NULL, sealed BLOB NOT NULL);")?;
let cipher = XChaCha20Poly1305::new((&key).into());
let expected = [AAD, &config.format_id()].concat();
let metadata: Option<Vec<u8>> = connection
.query_row("SELECT sealed FROM flag_meta WHERE id=1", [], |r| r.get(0))
.optional()?;
if let Some(metadata) = metadata {
ensure!(
open(&cipher, &metadata)? == expected,
"flag format differs from existing vault; keep the old configuration or use a separate vault"
);
} else {
let count: u64 =
connection.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r.get(0))?;
ensure!(count == 0, "flag vault metadata missing");
connection.execute(
"INSERT INTO flag_meta(id,sealed) VALUES(1,?1)",
[seal(&cipher, &expected)?],
)?;
}
let count: u64 =
connection.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r.get(0))?;
ensure!(count <= 100000, "flag vault exceeds entry limit");
let largest: u64 = connection.query_row(
"SELECT COALESCE(MAX(length(sealed)),0) FROM flag_entries",
[],
|r| r.get(0),
)?;
ensure!(largest <= 4096, "encrypted flag record exceeds size limit");
let mut worker = Self {
connection,
_lock: lock,
cipher,
config,
entries: HashMap::new(),
reverse: HashMap::new(),
expiry: BTreeSet::new(),
counters,
};
let rows = {
let mut statement = worker
.connection
.prepare("SELECT expires, sealed FROM flag_entries")?;
let rows =
statement.query_map([], |r| Ok((r.get::<_, u64>(0)?, r.get::<_, Vec<u8>>(1)?)))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
for (expires, sealed) in rows {
let entry: Entry = serde_json::from_slice(&open(&worker.cipher, &sealed)?)
.context("invalid flag vault record")?;
ensure!(
entry.expires == expires
&& crate::config::valid_id(&entry.service)
&& entry.original.len() == worker.config.width()
&& entry.token.len() == worker.config.width()
&& worker.config.matches_prefix(&entry.original)
&& worker.config.matches_prefix(&entry.token)
&& entry.original != entry.token,
"invalid flag mapping"
);
let original = (entry.service.clone(), entry.original.clone());
let token = (entry.service.clone(), entry.token.clone());
ensure!(
!worker.entries.contains_key(&original)
&& !worker.reverse.contains_key(&token)
&& !worker.entries.contains_key(&token)
&& !worker.reverse.contains_key(&original),
"ambiguous flag vault records"
);
worker.insert(entry);
}
worker.expire(now()?)?;
ensure!(
worker.entries.len() <= worker.config.max_entries,
"existing flag vault exceeds configured capacity"
);
worker
.counters
.entries
.store(worker.entries.len() as u64, Relaxed);
Ok(worker)
}
fn insert(&mut self, entry: Entry) {
let key = (entry.service.clone(), entry.original.clone());
self.reverse
.insert((entry.service.clone(), entry.token.clone()), key.clone());
self.expiry.insert((entry.expires, key.clone()));
self.entries.insert(key, entry);
}
fn expire(&mut self, time: u64) -> Result<()> {
if self
.expiry
.first()
.is_none_or(|(expires, _)| *expires > time)
{
return Ok(());
}
self.connection
.execute("DELETE FROM flag_entries WHERE expires<=?1", [time])?;
while self
.expiry
.first()
.is_some_and(|(expires, _)| *expires <= time)
{
let (_, key) = self.expiry.pop_first().expect("expiry entry");
if let Some(entry) = self.entries.remove(&key) {
self.reverse.remove(&(entry.service, entry.token));
}
}
self.counters
.entries
.store(self.entries.len() as u64, Relaxed);
Ok(())
}
fn translate(
&mut self,
service: &str,
values: Vec<Vec<u8>>,
encode: bool,
) -> Result<Vec<Vec<u8>>> {
let time = now()?;
self.expire(time)?;
if !encode {
let mut count = 0;
let result = values
.into_iter()
.map(|value| {
if let Some(key) = self.reverse.get(&(service.into(), value.clone())) {
count += 1;
self.entries[key].original.clone()
} else {
value
}
})
.collect();
self.counters.decoded.fetch_add(count, Relaxed);
return Ok(result);
}
let originals: HashSet<_> = values.iter().cloned().collect();
let mut staged: HashMap<Vec<u8>, Entry> = HashMap::new();
let mut tokens = HashSet::new();
let mut result = Vec::with_capacity(values.len());
let mut hits = 0;
for value in values {
let key = (service.into(), value.clone());
ensure!(
!self.reverse.contains_key(&key),
"known token received as an input flag; refusing ambiguous double translation"
);
if let Some(entry) = self.entries.get(&key).or_else(|| staged.get(&value)) {
result.push(entry.token.clone());
hits += 1;
continue;
}
ensure!(
self.entries.len() + staged.len() < self.config.max_entries,
"flag cache full; protected connection refused"
);
let mut token = None;
for _ in 0..128 {
let mut candidate = self.config.prefix.as_bytes().to_vec();
let mut rng = rand::rng();
candidate.extend((0..self.config.payload_length).map(|_| {
self.config.alphabet.as_bytes()[rng.random_range(0..self.config.alphabet.len())]
}));
candidate.extend_from_slice(self.config.suffix.as_bytes());
let k = (service.into(), candidate.clone());
if !originals.contains(&candidate)
&& !tokens.contains(&candidate)
&& !self.entries.contains_key(&k)
&& !self.reverse.contains_key(&k)
{
token = Some(candidate);
break;
}
}
let Some(token) = token else {
bail!("unable to allocate unique flag token");
};
tokens.insert(token.clone());
result.push(token.clone());
staged.insert(
value.clone(),
Entry {
service: service.into(),
original: value,
token,
expires: time + self.config.ttl_seconds,
},
);
}
if !staged.is_empty() {
let transaction = self.connection.transaction()?;
for entry in staged.values() {
let bytes = serde_json::to_vec(entry)?;
transaction.execute(
"INSERT INTO flag_entries(expires,sealed) VALUES(?1,?2)",
params![entry.expires, seal(&self.cipher, &bytes)?],
)?;
}
transaction
.commit()
.context("persist flag mappings before forwarding")?;
for entry in staged.into_values() {
self.insert(entry);
}
self.counters
.entries
.store(self.entries.len() as u64, Relaxed);
}
self.counters.hits.fetch_add(hits, Relaxed);
self.counters
.encoded
.fetch_add(result.len() as u64, Relaxed);
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expiration_removes_disk_and_memory_without_sliding_on_hits() {
let tmp = tempfile::tempdir().unwrap();
let cfg = FlagConfig {
database: tmp.path().join("vault.db"),
..FlagConfig::default()
};
let mut worker = Worker::open(cfg, [7; 32], Arc::new(Counters::default())).unwrap();
let original = [vec![b'A'; 31], vec![b'=']].concat();
let token = worker.translate("a", vec![original.clone()], true).unwrap();
let expiry = worker.entries.values().next().unwrap().expires;
assert_eq!(worker.translate("a", vec![original], true).unwrap(), token);
assert_eq!(worker.entries.values().next().unwrap().expires, expiry);
worker.expire(expiry).unwrap();
assert!(worker.entries.is_empty());
assert!(worker.reverse.is_empty());
assert_eq!(
worker
.connection
.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r
.get::<_, u64>(0))
.unwrap(),
0
);
assert_eq!(worker.translate("a", token.clone(), false).unwrap(), token);
}
}

View File

@ -2,6 +2,7 @@ pub mod auth;
pub mod config;
pub mod control;
pub mod engine;
pub mod flags;
pub mod scoreboard;
pub mod store;
pub mod web;

View File

@ -34,3 +34,13 @@ One native executable with an async TCP engine and embedded web UI. No Python/ru
- [complete] Fix dialog centering/overflow, selected-team wrapping and dark numeric inputs.
- [complete] Preserve invalid IP/add-form drafts; expose team add/rename and compact configuration/scoreboard help through existing API.
- [complete] Lightweight browser/functional checks and required Rust gates; preserve live demo state and migrate to data/demo before restart.
## Flag protection — 2026-10-23
- [complete] Optional per-service length-preserving tokenization; encrypted durable SQLite mapping cache; global fixed-shape format, environment key, TTL and capacity. Explain design before implementation.
- [complete] Streaming scanner handles all split positions, bounded candidate tail and timeout, EOF, unknown outgoing matches; encode once before replica fanout, decode primary replies only.
- [complete] UI opt-in/status, API/config/docs and tests for persistence, collision/capacity, wrong key, cache failure, isolation, TCP integrity/half-close.
- [complete] Fresh native/Orb gates and isolated smoke; preserve legacy UI and live demo state; journal/commit/checkpoint.
Design limits: stateful tokenization, not stateless same-length AEAD; XChaCha20-Poly1305 protects mappings on disk. Raw visible fixed-shape ASCII only; no TLS/compression/framing adapter. Incomplete possible flags may hold a protocol tail; bounded timeout closes rather than leaking. All clients get reverse translation, so not an authorization boundary. Disabled by default; no real game hosts/flags used.
Flag implementation issues recorded: cargo-add combined feature syntax rejected and corrected with separate calls; first module write preceded async mkdir and was retried after explicit directory creation; first test compile warned about unused Arc import, removed before Clippy gate. No failing runtime regressions so far. Final durability review upgraded SQLite synchronous from FULL to EXTRA; final native/Orb gates rerun on that source.

View File

@ -247,6 +247,7 @@ async fn automatic_failover_is_durable_and_primary_requests_keep_working() {
replicas: vec!["b".into()],
enabled: true,
auto_switch: true,
flag_protection: false,
replica_policy: ReplicaPolicy::Isolate,
}];
let controller = Controller::start(config.clone()).await.unwrap();
@ -287,6 +288,7 @@ fn direct_service(listen: SocketAddr, port: u16) -> ServiceConfig {
replicas: vec![],
enabled: true,
auto_switch: false,
flag_protection: false,
replica_policy: ReplicaPolicy::Isolate,
custom: true,
custom_ip: Some("127.0.0.1".parse().unwrap()),

408
tests/flag_protection.rs Normal file
View File

@ -0,0 +1,408 @@
use mirror_tool::{
config::{Config, NetworkConfig, ReplicaPolicy, ServiceConfig, Team},
engine::Engine,
flags::{FlagConfig, Transformer, Vault},
};
use std::{path::Path, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
time::timeout,
};
fn flag(byte: u8) -> Vec<u8> {
[vec![byte; 31], vec![b'=']].concat()
}
fn settings(root: &Path) -> FlagConfig {
FlagConfig {
database: root.join("vault.sqlite3"),
partial_timeout_ms: 100,
..FlagConfig::default()
}
}
async fn released(config: &FlagConfig) {
let mut name = config.database.as_os_str().to_os_string();
name.push(".lock");
timeout(Duration::from_secs(2), async {
loop {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&name)
.unwrap();
if fs2::FileExt::try_lock_exclusive(&file).is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
}
#[test]
fn opt_in_is_false_and_missing_vault_is_rejected() {
let base = r#"[[services]]
id="sample"
name="Sample"
listen="127.0.0.1:28181"
upstream_port=28182
"#;
let config: Config = toml::from_str(base).unwrap();
assert!(!config.services[0].flag_protection);
assert!(config.flags.is_none());
config.validate().unwrap();
let mut bad = config;
bad.services[0].flag_protection = true;
assert!(bad.validate().is_err());
for length in [0, 15, 129] {
let cfg = FlagConfig {
payload_length: length,
..FlagConfig::default()
};
assert!(cfg.validate().is_err());
}
}
#[tokio::test]
async fn every_split_binary_integrity_unknown_output_and_eof() {
let tmp = tempfile::tempdir().unwrap();
let vault = Vault::with_key(settings(tmp.path()), [7; 32]).unwrap();
let original = flag(b'A');
let token = vault
.translate("a", vec![original.clone()], true)
.await
.unwrap()
.remove(0);
assert_ne!(token, original);
assert_eq!(token.len(), original.len());
for split in 0..=original.len() {
let mut up = Transformer::new(vault.clone(), "a".into(), true);
let mut result = up.feed(&original[..split], false).await.unwrap();
result.extend(up.feed(&original[split..], false).await.unwrap());
result.extend(up.feed(&[], true).await.unwrap());
assert_eq!(result, token);
let mut down = Transformer::new(vault.clone(), "a".into(), false);
let mut result = down.feed(&token[..split], false).await.unwrap();
result.extend(down.feed(&token[split..], true).await.unwrap());
assert_eq!(result, original);
}
let input = [
b"\x00\xffPOST ".as_slice(),
&original,
b"\r\nbody=lowercase\x00",
&original,
b"!ABC",
]
.concat();
let mut up = Transformer::new(vault.clone(), "a".into(), true);
let mut output = Vec::new();
for chunk in input.chunks(3) {
output.extend(up.feed(chunk, false).await.unwrap());
}
output.extend(up.feed(&[], true).await.unwrap());
let expected = [
b"\x00\xffPOST ".as_slice(),
&token,
b"\r\nbody=lowercase\x00",
&token,
b"!ABC",
]
.concat();
assert_eq!(output, expected);
let unknown = flag(b'Z');
assert_eq!(
vault
.translate("a", vec![unknown.clone()], false)
.await
.unwrap(),
vec![unknown]
);
assert_eq!(
vault
.translate("another", vec![token.clone()], false)
.await
.unwrap(),
vec![token.clone()]
);
assert!(vault.translate("a", vec![token], true).await.is_err());
}
#[tokio::test]
async fn partial_candidate_times_out_without_plaintext_fallback() {
let tmp = tempfile::tempdir().unwrap();
let vault = Vault::with_key(settings(tmp.path()), [7; 32]).unwrap();
let mut codec = Transformer::new(vault.clone(), "a".into(), true);
assert!(codec.feed(b"AAAA", false).await.unwrap().is_empty());
let (_writer, mut reader) = tokio::io::duplex(64);
let mut buffer = [0; 32];
let error = timeout(Duration::from_secs(1), codec.read(&mut reader, &mut buffer))
.await
.unwrap()
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
assert_eq!(vault.status().errors, 1);
}
#[tokio::test]
async fn concurrent_repeats_share_one_durable_mapping_and_restart_restores() {
let tmp = tempfile::tempdir().unwrap();
let cfg = settings(tmp.path());
let vault = Vault::with_key(cfg.clone(), [7; 32]).unwrap();
assert!(Vault::with_key(cfg.clone(), [7; 32]).is_err()); // Single-owner lock.
let mut jobs = tokio::task::JoinSet::new();
for _ in 0..24 {
let v = vault.clone();
jobs.spawn(async move {
v.translate("a", vec![flag(b'A')], true)
.await
.unwrap()
.remove(0)
});
}
let first = jobs.join_next().await.unwrap().unwrap();
while let Some(result) = jobs.join_next().await {
assert_eq!(result.unwrap(), first);
}
assert_eq!(vault.status().entries, 1);
let other = vault
.translate("b", vec![flag(b'A')], true)
.await
.unwrap()
.remove(0);
assert_ne!(other, first);
let bytes = std::fs::read(&cfg.database).unwrap();
assert!(
!bytes
.windows(32)
.any(|s| s == flag(b'A') || s == first.as_slice())
);
drop(vault);
released(&cfg).await;
assert!(Vault::with_key(cfg.clone(), [8; 32]).is_err());
let mut changed = cfg.clone();
changed.suffix = "!".into();
assert!(Vault::with_key(changed, [7; 32]).is_err());
let vault = Vault::with_key(cfg.clone(), [7; 32]).unwrap();
assert_eq!(
vault
.translate("a", vec![first.clone()], false)
.await
.unwrap(),
vec![flag(b'A')]
);
assert_eq!(
vault.translate("a", vec![flag(b'A')], true).await.unwrap(),
vec![first]
);
drop(vault);
released(&cfg).await;
let db = rusqlite::Connection::open(&cfg.database).unwrap();
db.execute(
"UPDATE flag_entries SET sealed=zeroblob(length(sealed))",
[],
)
.unwrap();
drop(db);
assert!(Vault::with_key(cfg, [7; 32]).is_err());
}
#[tokio::test]
async fn capacity_and_disk_failure_do_not_publish_partial_mappings() {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = settings(tmp.path());
cfg.max_entries = 1;
let vault = Vault::with_key(cfg.clone(), [7; 32]).unwrap();
assert!(
vault
.translate("a", vec![flag(b'A'), flag(b'B')], true)
.await
.is_err()
);
assert_eq!(vault.status().entries, 0);
let db = rusqlite::Connection::open(&cfg.database).unwrap();
db.execute_batch("CREATE TRIGGER fail_insert BEFORE INSERT ON flag_entries BEGIN SELECT RAISE(ABORT,'synthetic write failure'); END;").unwrap();
assert!(vault.translate("a", vec![flag(b'A')], true).await.is_err());
assert_eq!(vault.status().entries, 0);
assert_eq!(
db.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r
.get::<_, u64>(0))
.unwrap(),
0
);
db.execute_batch("DROP TRIGGER fail_insert;").unwrap();
let token = vault.translate("a", vec![flag(b'A')], true).await.unwrap();
assert_eq!(vault.status().entries, 1);
assert!(vault.translate("a", vec![flag(b'B')], true).await.is_err());
assert_eq!(
vault.translate("a", token, false).await.unwrap(),
vec![flag(b'A')]
);
}
async fn capture_echo(listener: TcpListener) -> Vec<u8> {
let (mut stream, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).await.unwrap();
stream.write_all(&bytes).await.unwrap();
stream.shutdown().await.unwrap();
bytes
}
fn service(listen: std::net::SocketAddr, port: u16) -> ServiceConfig {
ServiceConfig {
id: "echo".into(),
name: "Echo".into(),
listen,
upstream_port: port,
primary: Some("a".into()),
replicas: vec!["b".into()],
enabled: true,
auto_switch: false,
replica_policy: ReplicaPolicy::Strict,
custom: false,
custom_ip: None,
flag_protection: true,
}
}
#[tokio::test]
async fn real_tcp_encodes_once_for_replicas_decodes_reply_and_toggle_is_per_connection() {
let tmp = tempfile::tempdir().unwrap();
let vault = Vault::with_key(settings(tmp.path()), [7; 32]).unwrap();
let primary = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = primary.local_addr().unwrap().port();
let replica = TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port))
.await
.unwrap();
let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap();
let listen = reservation.local_addr().unwrap();
drop(reservation);
let engine = Engine::with_flag_vault(NetworkConfig::default(), Some(vault.clone()));
let teams = vec![
Team {
id: "a".into(),
name: "A".into(),
address: "127.0.0.1".parse().unwrap(),
hidden: false,
},
Team {
id: "b".into(),
name: "B".into(),
address: "::1".parse().unwrap(),
hidden: false,
},
];
let mut cfg = service(listen, port);
engine.upsert(&cfg, &teams).await.unwrap();
let p = tokio::spawn(capture_echo(primary));
let r = tokio::spawn(capture_echo(replica));
let mut client = TcpStream::connect(listen).await.unwrap();
// Capture the route by sending a complete non-candidate prefix first.
client.write_all(b"hello\n").await.unwrap();
timeout(Duration::from_secs(2), async {
while engine.snapshots()["echo"].stats.bytes_up < 6 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
cfg.flag_protection = false;
engine.upsert(&cfg, &teams).await.unwrap();
let original = flag(b'A');
for chunk in original.chunks(3) {
client.write_all(chunk).await.unwrap();
}
client.shutdown().await.unwrap();
let mut reply = Vec::new();
timeout(Duration::from_secs(3), client.read_to_end(&mut reply))
.await
.unwrap()
.unwrap();
assert_eq!(reply, [b"hello\n".as_slice(), &original].concat());
let a = p.await.unwrap();
let b = timeout(Duration::from_secs(3), r).await.unwrap().unwrap();
assert_eq!(a, b);
assert_ne!(&a[6..], &original);
assert_eq!(a.len(), reply.len());
let primary = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
.await
.unwrap();
cfg.replicas.clear();
engine.upsert(&cfg, &teams).await.unwrap();
let p = tokio::spawn(capture_echo(primary));
let mut client = TcpStream::connect(listen).await.unwrap();
client.write_all(&original).await.unwrap();
client.shutdown().await.unwrap();
let mut reply = Vec::new();
client.read_to_end(&mut reply).await.unwrap();
assert_eq!(reply, original);
assert_eq!(p.await.unwrap(), original);
engine.shutdown().await;
}
#[tokio::test]
async fn custom_literal_format_preserves_http_lengths_and_repeated_matches() {
let tmp = tempfile::tempdir().unwrap();
let cfg = FlagConfig {
prefix: "FLAG{".into(),
suffix: "}".into(),
alphabet: "0123456789ABCDEF".into(),
payload_length: 32,
..settings(tmp.path())
};
let vault = Vault::with_key(cfg, [7; 32]).unwrap();
let flag = b"FLAG{0123456789ABCDEF0123456789ABCDEF}";
let body = [flag.as_slice(), b" ", flag].concat();
let header = format!("POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n", body.len());
let input = [header.as_bytes(), &body].concat();
let mut up = Transformer::new(vault.clone(), "a".into(), true);
let mut encrypted = Vec::new();
for c in input.chunks(1) {
encrypted.extend(up.feed(c, false).await.unwrap());
}
encrypted.extend(up.feed(&[], true).await.unwrap());
assert_eq!(encrypted.len(), input.len());
assert!(encrypted.starts_with(header.as_bytes()));
assert_ne!(encrypted, input);
assert_eq!(
&encrypted[header.len()..header.len() + flag.len()],
&encrypted[header.len() + flag.len() + 1..]
);
let mut down = Transformer::new(vault, "a".into(), false);
let output = down.feed(&encrypted, true).await.unwrap();
assert_eq!(output, input);
}
#[tokio::test]
async fn full_vault_fails_closed_before_primary_receives_flag() {
let tmp = tempfile::tempdir().unwrap();
let cfg = FlagConfig {
max_entries: 1,
..settings(tmp.path())
};
let vault = Vault::with_key(cfg, [7; 32]).unwrap();
vault
.translate("echo", vec![flag(b'A')], true)
.await
.unwrap();
let primary = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = primary.local_addr().unwrap().port();
let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap();
let listen = reservation.local_addr().unwrap();
drop(reservation);
let engine = Engine::with_flag_vault(NetworkConfig::default(), Some(vault));
let mut cfg = service(listen, port);
cfg.replicas.clear();
let teams = vec![Team {
id: "a".into(),
name: "A".into(),
address: "127.0.0.1".parse().unwrap(),
hidden: false,
}];
engine.upsert(&cfg, &teams).await.unwrap();
let p = tokio::spawn(capture_echo(primary));
let mut client = TcpStream::connect(listen).await.unwrap();
client.write_all(&flag(b'B')).await.unwrap();
client.shutdown().await.unwrap();
let mut reply = Vec::new();
let _ = timeout(Duration::from_secs(2), client.read_to_end(&mut reply))
.await
.unwrap();
assert!(p.await.unwrap().is_empty());
assert!(reply.is_empty());
assert_eq!(engine.snapshots()["echo"].stats.io_errors, 1);
engine.shutdown().await;
}

View File

@ -42,6 +42,7 @@ fn service(listen: SocketAddr, upstream: SocketAddr) -> ServiceConfig {
replicas: vec![],
enabled: true,
auto_switch: false,
flag_protection: false,
replica_policy: ReplicaPolicy::Isolate,
}
}

View File

@ -39,6 +39,7 @@ async fn partial_http_body_cannot_keep_process_or_proxy_listener_alive() {
replicas: vec![],
enabled: true,
auto_switch: false,
flag_protection: false,
replica_policy: ReplicaPolicy::Isolate,
custom: false,
custom_ip: None,

View File

@ -938,3 +938,7 @@ button:focus-visible, [role=button]:focus-visible { outline: 2px solid #0a84ff;
.help-body pre { padding: 12px; border-radius: 8px; background: rgba(0,0,0,.25); color: #ddd; font-size: 12px; }
.header-actions { gap: 8px; flex-shrink: 0; }
@media (max-width: 420px) { .section-title { font-size: 19px; } .header-actions { gap: 5px; } }
.flag-option { display: flex; align-items: center; gap: 8px; }
.flag-option input { accent-color: #0a84ff; width: 16px; height: 16px; }
.flags-enabled .task-title-button::after { content: " 🔒"; font-size: 12px; }

View File

@ -48,7 +48,7 @@
return data;
}
function config(service) {
return Object.fromEntries(["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null }[key])]));
return Object.fromEntries(["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy", "flag_protection", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null, flag_protection: false }[key])]));
}
async function refresh() { render(await api("/api/snapshot")); watchUpdates(); }
async function mutate(path, method, body, errorId) {
@ -107,6 +107,7 @@
const service = serviceById(id); if (!service || state.busy || state.stale) return;
state.editId = id; $("settings-error").textContent = "";
$("service-name").value = service.name; $("service-name").setCustomValidity(""); $("service-listen").value = service.listen;
$("service-flags").checked = Boolean(service.flag_protection);
$("service-port").value = service.upstream_port; $("service-policy").value = service.replica_policy;
$("settingsModal").showModal();
}
@ -194,13 +195,18 @@
$("board-error").textContent = board.last_error || "";
$("add-service").disabled = locked; $("manage-teams").disabled = locked; $("logout").disabled = state.busy;
for (const form of [$("customProxyForm"), $("settings-form"), $("team-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked;
$("service-flags").disabled = locked || !current.flag_protection?.ready;
const vault = current.flag_protection;
$("flags-status").textContent = vault ? `Кеш подмены флагов: ${vault.ready ? "готов" : "недоступен"}; записей ${vault.entries}/${vault.capacity}; подменено ${vault.encoded}, восстановлено ${vault.decoded}, попаданий ${vault.cache_hits}, ошибок ${vault.errors}.` : "Подмена флагов не настроена. Для включения задайте [flags] в TOML и ключ в окружении, затем перезапустите процесс.";
$("flags-hint").textContent = vault?.ready ? "По умолчанию выключено. Применяется к новым соединениям. Формат и срок кеша задаются в TOML. Только открытый несжатый трафик; это не проверка прав клиента." : "Сначала настройте [flags] и ключ в окружении (см. справку ⓘ).";
for (const node of document.querySelectorAll("[data-close]")) node.disabled = state.busy;
const ids = new Set(current.services.map((service) => service.id));
for (const [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); }
for (const service of current.services) {
let card = state.cards.get(service.id);
if (!card) { card = createCard(service); state.cards.set(service.id, card); }
card.title.textContent = service.name; card.title.title = `Настройки: ${service.name}`; card.port.textContent = `:${portOf(service)}`;
card.root.classList.toggle("flags-enabled", Boolean(service.flag_protection));
card.title.textContent = service.name; card.title.title = `Настройки: ${service.name}${service.flag_protection ? " · подмена флагов включена" : ""}`; card.port.textContent = `:${portOf(service)}`;
card.title.setAttribute("aria-label", `Настройки сервиса ${service.name}`); card.title.disabled = locked;
card.auto.classList.toggle("active", service.auto_switch); card.auto.setAttribute("aria-pressed", String(service.auto_switch));
card.auto.title = service.auto_switch ? "Авто-переключение включено" : "Авто-переключение"; card.auto.disabled = locked;
@ -280,7 +286,7 @@
$("settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!$("service-name").value.trim()) { $("settings-error").textContent = "Введите название"; return; }
if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value }, "settings-error")) $("settingsModal").close();
if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value, flag_protection: $("service-flags").checked }, "settings-error")) $("settingsModal").close();
});
// Whitespace-only names pass HTML required validation; reject before submission.
for (const id of ["customName", "service-name", "team-name"]) {

View File

@ -56,6 +56,7 @@
<div class="mb-3"><label for="service-listen" class="form-label">Адрес и порт прокси</label><input id="service-listen" class="input-modern w-100" required></div>
<div class="mb-3"><label for="service-port" class="form-label">Порт сервиса команды</label><input id="service-port" type="number" step="1" inputmode="numeric" min="1" max="65535" class="input-modern w-100" required></div>
<div class="mb-3"><label for="service-policy" class="form-label">При медленной реплике</label><select id="service-policy" class="input-modern w-100"><option value="isolate">Отключить реплику, сохранить скорость primary</option><option value="strict">Ждать реплику, замедлить общий поток</option></select></div>
<div class="mb-3"><label class="form-label flag-option" for="service-flags"><input id="service-flags" type="checkbox" aria-describedby="flags-hint"> Подмена флагов</label><p id="flags-hint" class="form-hint"></p></div>
<p id="settings-error" class="form-error" role="alert"></p>
</div>
<div class="modal-footer"><button type="button" class="add-button cancel-button" data-close="settingsModal">Отмена</button><button type="submit" class="add-button">Сохранить</button></div>
@ -95,6 +96,21 @@ url = "http://scoreboard.example:8080"
poll_interval_ms = 3000
stale_after_ms = 15000</pre>
<p>Адрес замените на URL вашей игры. Имена команд и сервисов должны совпадать с scoreboard. После правки TOML перезапустите процесс. Настройки маршрутов из панели применяются сразу.</p>
<h6>Подмена флагов — опционально</h6>
<p id="flags-status" role="status"></p>
<p>В настройках сервиса включается отдельно, по умолчанию выключена. Входящий флаг заменяется токеном той же длины; известный токен в ответе восстанавливается. Кеш хранится зашифрованным, ключ — только в окружении процесса.</p>
<pre>[flags]
database = "data/flags.sqlite3"
key_env = "MIRROR_FLAG_KEY"
prefix = ""
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
payload_length = 31
suffix = "="
ttl_seconds = 86400
max_entries = 10000
partial_timeout_ms = 5000</pre>
<p>Ключ: 32 случайных байта в виде 64 hex-символов. Храните его отдельно от базы и используйте после рестарта. Без прежнего ключа кеш не откроется. TTL должен превышать срок жизни флагов и максимальную задержку ответов. Формат — буквальный prefix + payload из alphabet + буквальный suffix, не произвольный regex.</p>
<p>Подходит для открытого несжатого TCP с совместимым протоколом. Незаконченный возможный флаг удерживается до продолжения или EOF; по тайм-ауту соединение закрывается с ошибкой. Изменение длины, подписанные сообщения, TLS и сжатие не поддерживаются. Ответы восстанавливаются всем клиентам, поэтому это не защита от эксплуатации сервиса через прокси.</p>
<h6>Управление маршрутами</h6>
<p>Кружок выбирает primary, строка — реплику. Робот включает автоматический выбор из назначенных реплик. Новые соединения используют новый маршрут; открытые остаются на прежнем.</p>
<p>Название сервиса открывает настройки портов и режима реплик. При <code>isolate</code> медленная реплика отключается; при <code>strict</code> общий поток ждёт её.</p>