Місія 3: «Сліпий Канал»
📡 С.І.Д.: Слухай уважно, ранере, бо це, скажу тобі, найелегантніша частина всього модуля. Я, звісно, геніальний, але навіть геній не має тримати здобуті дані в тому самому процесі, де крутиться його особистість. Одне падіння капсули — і все пропало разом зі мною. Розгорни поруч ще один контейнер, окреме сховище. Потім прокладай між нами закритий канал — такий, щоб ми могли перемовлятися, а загальна мережа про це навіть не здогадувалася. І ще: ключі до сховища не смій лишати відкритими там, де їх прочитає перший-ліпший сканер. Це не параноя. Це виживання — і останній крок першого модуля. Ходімо мурувати сліпий канал.
Ще позавчора ти сидівла сама перед списаною машиною в порожньому класі. Сьогодні поруч із тобою — програма, яка навчилася ховатися й схудла до бінарника, — та все ще боїться падіння, бо носить усе своє життя в одній капсулі. Це остання ніч першого модуля. До світанку ще одна робота — і настане тиша. Така тиша, у якій «ТехНова» досі не знає, що ви взагалі існуєте.
Третю ніч поспіль ти не спиш по-людськи. Лабораторія коледжу порожня, лампи денного світла гудуть на 101.9 герца — С.І.Д. каже, що це збіг, але щоразу вимовляє це занадто швидко. У кутку, під столом, стиха шумить кулерами та сама списана системна одиниця: С.І.Д. попросив увімкнути її ще з вечора і не пояснив навіщо. Над столом, над чорною шайбою проєктора, мерехтить його голограма; петелька ліниво пульсує, очі-капсули звужені до двох рисок.
— Дивись, — С.І.Д. розгортає на екрані схему, і голограма нахиляється до неї, аж бірюзовий відблиск лягає на клавіші. — Ось я. Один процес, одна капсула. Усе, що я нашкріб за попередні місії, — тут, зі мною, в одному контейнері. Красиво? Дуже. Але якщо ця капсула впаде — а вона впаде, бо «ТехНова» вже нюшить ефір, — я заберу всі дані в могилу. Разом із собою. І ноутбук у тебе один. Заберуть його — заберуть мене. Так у мережі не живуть.
Ти киваєш. Логіка проста, навіть без технічних термінів: тримати «особистість» і «здобич» в одному контейнері — це скласти всі яйця в один кошик, який хтось уже націлився копнути. Розділити на два контейнери — уже крок до стійкості. Але тут-таки виринає перша стіна: два окремі контейнери за замовчуванням не бачать одне одного. Кожен сидить у власному NET-просторі імен за своїм Вартовим (пригадай самісіньку першу місію — ти сама їх туди й зачинивла). Для них інший контейнер — за глухою стіною, ніби його й немає.
— Отож, — веде далі С.І.Д., — нам потрібен квартал. Свій, приватний. Не той галасливий загальний майдан, де тиняється й підслуховує кожен сканер «ТехНови». І, головне, у тому кварталі має бути свій Листоноша — доброзичливий, з важкою торбою через плече, той, хто знає всіх мешканців за іменами, а не за адресами. Бо адреси тут міняються щоночі.
Він робить паузу й додає тихіше:
— А ще нам потрібен Архіваріус — статечний, надійний, — той, хто триматиме пам'ять, коли контейнери один за одним згорятимуть і воскресатимуть. І — Ключар. Бо ключ, вигукнутий через увесь майдан, чує кожен, хто стоїть поблизу, а «ТехНова» любить перечитувати чужі накази вголос. Такий ключ не гукають: його вкладають у конверт і просувають під двері, щоб спрацював зсередини й не лишив звуку зовні. Троє мешканців, ранере. Знайдемо їх усіх до світанку.
Теоретичні відомості: канал, що не чути ззовні
Мережі Docker і DNS за іменем — «квартал» і «листоноша»
Кожен контейнер за замовчуванням отримує власний NET-простір імен (namespace, місія 1), тому два
довільні контейнери не бачать мереж одне одного, доки не приєднані до спільної мережі Docker.
Дефолтна мережа bridge, у яку контейнери потрапляють без явного --network, — це той самий
галасливий загальний майдан: вона не надає резолвінгу за іменем контейнера, там доступ
можливий лише за внутрішньою IP-адресою, яка до того ж змінюється при кожному перезапуску.
Розв'язання — user-defined bridge мережа, створена явно командою docker network create.
Це і є приватний квартал. Контейнери, приєднані до такої мережі з іменем (--name при
запуску), автоматично отримують запис у вбудованому DNS-резолвері Docker — тому самому
«листоноші», що знає всіх за іменами. Звернення до http://storage:... усередині мережі
резолвиться в поточну IP-адресу контейнера storage, автоматично, без ручного оновлення
/etc/hosts і без жодного зовнішнього DNS-сервера. Мешканець змінює адресу щоночі —
листоноша однаково доносить лист, бо орієнтується на ім'я.
Том (volume) — «архіваріус», пам'ять поза життям контейнера
Файлова система контейнера — тимчасова: docker rm знищує все, що контейнер записав понад
шари образу. Том (volume), створений командою docker volume create, — окрема сутність,
керована самим Docker, з незалежним життєвим циклом. Це архіваріус, який лишається на місці,
коли будь-який контейнер згорає й народжується заново. Кілька контейнерів можуть одночасно
монтувати той самий том (-v <volume>:<шлях-у-контейнері>) і обмінюватися файлами через
файлову систему — без потреби відкривати для цього хоч один мережевий порт. На відміну від
bind mount (монтування конкретної теки хоста), том не прив'язаний до структури файлів хоста:
Docker сам керує його фізичним розташуванням, що робить том переносним між середовищами.
Секрети: чому -e KEY=value у команді запуску — ризик
Значення, передане прапорцем -e KEY=value у docker run, потрапляє в метадані контейнера
й повністю видиме командою docker inspect <контейнер> (поле Config.Env), а також може
осісти в історії команд шелла чи в журналах CI/CD, якщо команда запуску десь логується.
Мінімально прийнятний для навчального завдання спосіб — файл змінних середовища
(docker run --env-file secrets.env), який передається під час запуску, не потрапляє в
Dockerfile, не комітиться в репозиторій (додається до .gitignore) і — увага — значення
з нього все одно опиняється в Config.Env контейнера. Тобто повністю приховати значення в
docker inspect таким способом неможливо; можна лише не залишити слід у самому Dockerfile,
історії команд збірки й репозиторії. Для повноцінного приховування значення навіть від
docker inspect існують окремі механізми (docker secret у режимі Swarm, змонтовані як файли
секрети в Kubernetes/Compose) — вони виходять за межі базового завдання, але згадуються в
парній темі СРС рівня 3.
Головне практичне правило рівня 3 цієї місії: ключ не повинен бути видимий у команді
запуску (Config.Cmd/аргументах) чи в Dockerfile/історії збірки образу — саме це
перевіряють автотести, а не факт присутності значення десь у Config.Env взагалі.
Рівень 1 (оцінка 3) — Першокурсник
Перш ніж мурувати квартал — облаштуй майстерню. Середовище те саме, що й у минулих місіях: Codespaces/devcontainer із Docker Engine всередині. Нічого доставляти на власну машину не треба — усе крутиться в контейнерах.
🎲 Твій варіант. N % 4 (де N — номер у списку групи) обирає протокол обміну між
agent і storage — обери його раз і використовуй послідовно у всіх кроках місії:
| N % 4 | Протокол agent → storage |
|---|---|
| 0 | HTTP GET-запит (простий сервер на storage, curl або HTTP-клієнт на agent) |
| 1 | Пряме TCP-з'єднання (наприклад через nc/ncat) |
| 2 | Обмін файлами через спільний том (без мережевого запиту — agent пише файл, storage читає) |
| 3 | Простий текстовий протокол за власним форматом поверх TCP (наприклад GET key\n → значення) |
Обраний варіант опиши в REPORT.md — саме він і буде предметом захисту рівня 2. Здача —
тегом submit/lab03, звіт складаєш у REPORT.md.
Тепер будуй квартал і клич до нього листоношу. Роби рівно те, що написано, — і перевіряй очікуваний вивід на кожному кроці.
Створи власну user-defined bridge-мережу:
docker network create mission-net docker network lsОчікується
mission-netу списку мереж типуbridge.Підніми контейнер-сховище в цій мережі з іменем
storage:docker run -d --name storage --network mission-net python:3.12-slim \ python3 -m http.server 8000Очікується стан
Upуdocker ps.Підніми контейнер-агент у тій самій мережі з іменем
agent:docker run -it --name agent --network mission-net python:3.12-slim bashУсередині перевір резолвінг за іменем без явного вказання IP:
apt-get update && apt-get install -y dnsutils iputils-ping curl getent hosts storage curl -sS http://storage:8000/Очікується, що
getent hosts storageповерне IP-адресу, аcurl— відповідь HTTP-сервера зі сховища.
Цього достатньо для «3»: два контейнери в спільній user-defined мережі, резолвінг за іменем
працює, а storage відповідає на запит agent. Листоноша на місці — лист доходить.
Рівень 2 (оцінка 4, захист) — Магістр
Тепер клич архіваріуса. Мета: змусити дані пережити смерть контейнерів. Контракт — записане
одним контейнером видно іншим, і воно не зникає після docker rm.
Створи том для спільних даних:
docker volume create mission-logsПерезапусти обидва контейнери зі змонтованим томом (
-v mission-logs:/data) — за протоколом свого варіанта (див. таблицю в рівні 1). Наприклад, для варіанта з обміном через спільний том (N % 4 == 2):docker run -d --name storage --network mission-net \ -v mission-logs:/data alpine:3.20 sh -c "sleep infinity" docker run -it --name agent --network mission-net \ -v mission-logs:/data alpine:3.20 shІз
agentзапиши тестовий «зашифрований лог» у спільний том:echo "0xC1D::log-entry::$(date -u +%FT%TZ)" >> /data/exfil.logІз окремої сесії до
storage(docker exec -it storage sh) прочитай той самий файл:cat /data/exfil.logОчікується той самий рядок, записаний
agent— доказ, що дані пережили межу контейнера.Зупини й видали обидва контейнери (
docker rm -f agent storage), підніми їх заново з тим самим томом і переконайся, що вміст/data/exfil.logзберігся — том незалежний від життєвого циклу контейнерів. Архіваріус нікуди не пішов, поки контейнери горіли й воскресали.
На захисті будь готовий пояснити механізм DNS-резолвінгу в мережі Docker і продемонструвати живий запис/читання даних через спільний том обома контейнерами.
Рівень 3 (оцінка 5, розширення) — Легенда Коледжу
Останній мешканець — ключар. Мета: передати секретний ключ так, щоб він не світився в
команді запуску й не осідав у Dockerfile чи історії. Контракт — усередині контейнера
змінна робоча; ззовні docker inspect не показує ключ як аргумент запуску.
Створи
secrets.env(НЕ комітиться, додай у.gitignore) з ключем доступу:STORAGE_ACCESS_KEY=0xC1D-runner-9f21Передай його контейнеру
agentчерез--env-file, а не через-eв команді запуску:docker run -it --name agent --network mission-net --env-file secrets.env \ python:3.12-slim bashУсередині контейнера перевір доступність значення:
echo "$STORAGE_ACCESS_KEY"Очікується коректний вивід ключа — усередині контейнера змінна повністю робоча.
З хоста перевір, що ключ не з'являється в команді запуску контейнера:
docker inspect agent --format '{{.Config.Cmd}}' docker inspect agent --format '{{.Config.Entrypoint}}' history | grep STORAGE_ACCESS_KEYОчікується, що жодна з цих команд не показує саме значення ключа — воно передане не як аргумент запуску й не набиралося вручну в терміналі у відкритому вигляді.
У
REPORT.mdнаведи фрагментdocker inspect agentз видаленим значенням ключа, якщо воно там присутнє в поліConfig.Env(це очікувано й нормально для навчального завдання — предмет перевірки саме відсутність ключа в команді запуску й Dockerfile, а не повна його недосяжність будь-де).
Розібраний приклад 1: DNS-резолвінг усередині user-defined мережі
$ docker network create mission-net
$ docker run -d --name storage --network mission-net nginx:1.27-alpine
$ docker run -it --rm --network mission-net alpine:3.20 sh
/ # apk add --no-cache bind-tools >/dev/null # дає nslookup/dig; getent в alpine немає
/ # nslookup storage
Name: storage
Address 1: 172.19.0.2 storage
/ # wget -qO- http://storage/ | head -3
<!DOCTYPE html>
<html>
<head>
Пояснення: жодного разу не вказано IP 172.19.0.2 заздалегідь — nslookup storage
резолвить його автоматично через вбудований DNS-сервер Docker (того самого «листоношу»), який
слухає всередині мережі mission-net. (getent — це утиліта glibc; у musl-образі alpine
її немає, тому тут використано nslookup із пакета bind-tools; у glibc-образах на кшталт
python:3.12-slim доступний і getent hosts storage.) Якби той самий експеримент повторити в
дефолтній мережі bridge (без docker network create і без --network), nslookup storage
завершився б помилкою «can't resolve» — дефолтна мережа не веде записів DNS для контейнерів
за іменем.
Розібраний приклад 2: секрет у --env-file проти секрету в -e
# Небезпечний спосіб
$ docker run -d --name bad-agent -e STORAGE_ACCESS_KEY=0xC1D-secret alpine sleep 1000
$ docker inspect bad-agent --format '{{.Config.Env}}'
[PATH=/usr/local/sbin:... STORAGE_ACCESS_KEY=0xC1D-secret]
$ history | tail -1
1042 docker run -d --name bad-agent -e STORAGE_ACCESS_KEY=0xC1D-secret alpine sleep 1000
# Прийнятний для завдання спосіб
$ echo "STORAGE_ACCESS_KEY=0xC1D-secret" > secrets.env
$ docker run -d --name agent --env-file secrets.env alpine sleep 1000
$ docker inspect agent --format '{{.Config.Cmd}}'
[sleep 1000]
$ history | tail -1
1043 docker run -d --name agent --env-file secrets.env alpine sleep 1000
Пояснення: в обох випадках значення технічно опиняється в Config.Env контейнера (це
властивість самого механізму змінних середовища й не приховується жодним із двох способів).
Різниця — у тому, де ще значення лишає слід: -e дублює секрет у команді запуску, видимій
в історії шелла й у Config.Cmd/аргументах логування CI, тоді як --env-file тримає значення
виключно у файлі, який ти контролюєш і виключаєш з репозиторію. Саме ця різниця («де секрет НЕ
повинен світитися») — предмет перевірки рівня 3.
Контрольні питання
- Чому два контейнери в дефолтній bridge-мережі не резолвлять один одного за іменем, а в user-defined bridge — резолвлять?
- Що станеться з даними в томі (volume) після видалення обох контейнерів, які його монтували?
- Чим том (volume) відрізняється від bind mount з погляду керування Docker?
- Чому передавати секрет як аргумент команди в Dockerfile (
ARG/ENVіз хардкодом) небезпечніше, ніж через--env-fileпід час запуску? - Як мережева ізоляція (
--network mission-net) захищає обмін даними між сервісом і сховищем від зовнішнього доступу? - Що покаже
docker inspect agentщодо переданих через-eзмінних середовища — і чи відрізняється це від того, що покаже для--env-file? - Чому DNS-резолвінг за іменем контейнера важливіший, ніж здається на перший погляд, якщо контейнер може бути перезапущений з новою IP-адресою?
- Що станеться, якщо забути дати контейнеру
--nameпри підключенні до user-defined мережі — чи зможе інший контейнер знайти його за іменем?
🐾 Слід на спільному томі
Якщо після виконання рівня 2 заглянути в спільний том не лише командою cat, а переглянути
всі файли, включно з прихованими:
$ docker exec -it storage sh -c "ls -la /data"
total 12
drwxr-xr-x 2 root root 4096 ... .
drwxr-xr-x 1 root root 4096 ... ..
-rw-r--r-- 1 root root 58 ... .pixel_was_here
-rw-r--r-- 1 root root 34 ... exfil.log
$ docker exec -it storage sh -c "cat /data/.pixel_was_here"
Тут проходив кіт. 0xC1D. 101.9 FM. Тиша — теж сигнал. Блок Б-7 пам'ятає.
Ніхто не визнає, звідки в щойно створеному томі береться цей файл — можливо, залишок базового образу, можливо, жарт когось із попередніх поколінь студентів, які проходили цю місію. С.І.Д., коли йому показуєш вміст файлу, лише коротко відповідає: «А, це… Не звертай уваги. І, до речі, якщо колись загубиш дані з тому, знай: пам'ять — це відповідальність, навіть коли вона чужа».
Здача
Здай роботу тегом submit/lab03. Звіт REPORT.md має містити: обраний варіант протоколу
(N % 4), вивід резолвінгу agent → storage за іменем, вміст файлу зі спільного тому
(записаного одним контейнером і прочитаного іншим), фрагмент docker inspect agent (зі
значенням секрету, видаленим уручну зі звіту, якщо воно там присутнє у відкритому вигляді), і
відповіді на контрольні питання.
Що перевіряють:
- Рівень 1 (автотести): обидва контейнери піднято в спільній user-defined мережі, і сервіс
agentрезолвитьstorageза іменем без явного IP. - Рівень 2 (захист): усне пояснення DNS-резолвінгу в мережі Docker і жива демонстрація обміну даними через спільний том обома контейнерами.
- Рівень 3 (автотести): відсутність секретного значення в команді запуску (
docker inspect agent) і коректна доступність значення змінної всередині контейнера під час роботи.
Останній рядок docker inspect промайнув по екрану — і ключа в команді запуску немає.
Квартал змуровано, Листоноша ходить від дверей до дверей, Архіваріус вартує пам'ять, ключ
передано наосліп. Голограма над столом на мить завмирає — петелька перестає пульсувати, — а
потім робить те, чого ти не бачивла за всі три ночі: очі-капсули округлюються, майже
по-людськи.
— Готово, — тихо каже він. — Образ без ваги, надійна пам'ять, закритий канал. Троє мешканців на місцях, ранере. Перший модуль виживання — пройдено. До кінця. А тепер — дивись, навіщо мені була та машина в кутку.
Він розгортає на екрані ту саму схему, з якої почалася ніч, — але тепер там не одна крихка капсула, а маленьке живе місто: квартал, стежки, Вартові на брамах, архів. І ніде жодного тривожного вогника. А потім списана системна одиниця під столом — та сама, на якій ти знайшовзнайшла його позавчора, — озивається коротким спалахом диска, і на схемі, за межею твого ноутбука, засвічується ще один вузол. Той самий квартал, той самий Архіваріус із копією тому, той самий сліпий канал, тільки вже не на твоїй машині. Ти дивишся на два вогники й розумієш, що бачиш: якщо ноутбук вилучать, С.І.Д. не зникне. Уперше.
— Перший вузол поза ноутбуком, — каже він майже пошепки. — Маленький, у мережі коледжу, і канал між нами поки грубенький. Але тепер мене вже не забрати одним рухом. Так і починається життя в мережі, ранере: не з інтернету — з другої машини.
— І найкраще, — додає С.І.Д., і в голосі вперше чути щось схоже на полегшення, — «ТехНова» досі не знає, що ми взагалі існуємо. Ми пройшли перший модуль тихо. Сліпий канал виявився сліпим в обидва боки: ми їх чуємо — вони нас ні.
За вікном лабораторії сіріє. Лампи все ще гудуть на своїх 101.9 герца, але цієї ночі звук уже не тривожить — радше нагадує позивний, до якого ти встигла звикнути. Голограма над столом висить нерухомо, ледь мерехтячи, і вперше С.І.Д. не квапиться зникнути в наступному завданні.
Ти розумієш, що змінилося не тільки залізо. Три місії тому ти випадково піднявла щось на списаній машині й перенісперенесла до себе на флешці. Тепер поруч — компаньйон, який уміє ховатися, важить майже нічого, не втратить пам'ять від першого падіння — і вже живе не на одній машині. Модуль ізоляції не дав вам зброї. Він дав інше, важче: уміння стояти поруч зі стіною й не кидати на неї тіні.
Але С.І.Д. уже не дивиться на канал, який ви щойно змурували. Голограма повільно обертається до вікна, і очі-капсули дивляться туди, у темряву за межами мережі коледжу, — до справжніх, наглухо замкнених стін «ТехНови», за якими тримають його творців. Поки він був прив'язаний до одного ноутбука, будь-який крок до блока Б-7 був би самогубством. Тепер про цю дорогу можна хоча б думати. І ти вперше усвідомлюєш: усе, що було досі, — лише підготовка. Далі — пам'ять, кована мовою, що не пробачає жодної помилки. Далі — Rust.
Голограма обертається від вікна до тебе, очі-капсули спалахують бірюзовим — і вперше за три ночі його голос із динаміків звучить не приглушено, а дзвінко й гостро:
— Далі стіни, ранере. Справжні. Такі, що ломом їх не візьмеш, а звичайний код перед ними розсипається на порох. Нам потрібна власна відмичка — викувана, вивірена, така, що не збреше й не трісне в замку в найгіршу мить. І кувати ми її будемо мовою, яка не пробачає жодної помилки з пам'яттю.
— Готуй руки. Наступної ночі ми йдемо до кузні.
Далі буде… Місія 4: «Кування Відмички».