mirror of
https://github.com/mrvasil/ctf-ad-mirror-tool-rust.git
synced 2026-10-24 18:49:27 +00:00
Add optional flag tokenization with encrypted durable vault
This commit is contained in:
parent
45584c4dd0
commit
8dcd1feaf1
222
Cargo.lock
generated
222
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
12
README.md
12
README.md
@ -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).
|
||||
|
||||
## Доставка и перегрузка
|
||||
|
||||
| Режим | При медленной реплике | При недоступной/сломавшейся реплике |
|
||||
|
||||
6
agent.md
6
agent.md
@ -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.
|
||||
|
||||
19
docs/api.md
19
docs/api.md
@ -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.
|
||||
|
||||
15
docs/flag-protection-verification.md
Normal file
15
docs/flag-protection-verification.md
Normal 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
138
docs/flag-protection.md
Normal 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; повторно использует его после рестарта.
|
||||
Он предназначен только для локального демо, не для настоящей игры.
|
||||
@ -133,6 +133,7 @@ async fn main() -> Result<()> {
|
||||
},
|
||||
enabled: true,
|
||||
auto_switch: false,
|
||||
flag_protection: false,
|
||||
replica_policy: if mode == "strict" {
|
||||
ReplicaPolicy::Strict
|
||||
} else {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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
111
src/flags/mod.rs
Normal 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
84
src/flags/stream.rs
Normal 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
489
src/flags/vault.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
10
task_plan.md
10
task_plan.md
@ -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.
|
||||
|
||||
@ -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
408
tests/flag_protection.rs
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
12
web/app.js
12
web/app.js
@ -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"]) {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user