Відкат на живому залізі: пароль адміністратора лежав відкритим
Some checks failed
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m13s
CI / server (push) Successful in 1m34s
CI / dbtest (push) Successful in 1m52s
CI / agent (push) Has been cancelled

Механізм відкату існував з 0035 і жодного разу не виконувався на
справжньому обладнанні. Прогін до межі заліза (план -> заявка ->
погодження, без заливки) дав три правильні відмови й одну знахідку.

D-Link питає пароль інтерактивно, і `show config` віддає відповіді
окремими рядками без ключових слів. Порядкове маскування за зразками
такий рядок не бачить, тож пароль адміністратора живого комутатора
лежав у плані відкату, у ncm.rollbacks і в git-дзеркалі — маскування
перед записом у git немає взагалі.

redactLines отримав стан: після рядка заведення облікового запису до
двох односкладових рядків маскуються. Ім'я запису лишається видимим.

Дорогою: перший рядок збереженого конфігу — відлуння команди
(`Command: show config`), і планувальникклав його в команди до заливки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-28 23:42:43 +03:00
parent 6d1786e111
commit c83324ae3e
11 changed files with 1449 additions and 37 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
# Що НЕ потрапляє в контекст збірки.
#
# Файл з'явився через пісочницю оновлення: вона розгортає повне дерево
# попередньої версії в .sandbox-prev/, і без цього рядка кожен
# `docker compose build` тягнув би подвійні вихідні тексти разом зі
# зібраним веб-інтерфейсом. Помітно це не як помилка, а як «збірка чомусь
# стала вдвічі довшою».
.sandbox-prev/
# Секрети. У Dockerfile їх не копіює ніхто, але контекст іде демонові
# цілком — тобто файл із паролями бази й ключем шифрування подорожує
# туди без потреби. Ціна виключення нульова, ціна недогляду — ні.
.env
.env.*
!.env.example

View file

@ -1,9 +1,9 @@
# Складання і перевірки NetPulse на Forgejo Actions. # Складання і перевірки NetPulse на Forgejo Actions.
# #
# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`), # Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`
# плюс три речі, яких скрипт зробити не може: інтеграційні тести проти # і `sh scripts/dbtest.sh`), плюс три речі, яких перший скрипт зробити не
# живої бази, звірка каталогу профілів зі згенерованою міграцією і # може: інтеграційні тести проти живої бази, звірка каталогу профілів зі
# перевірка закінчень рядків. # згенерованою міграцією і перевірка закінчень рядків.
# #
# ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА САМІ ТЕСТИ. Інтеграційні тести grpcapi мовчки # ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА САМІ ТЕСТИ. Інтеграційні тести grpcapi мовчки
# пропускались півтора року: без NETPULSE_TEST_DSN вони роблять t.Skip, # пропускались півтора року: без NETPULSE_TEST_DSN вони роблять t.Skip,
@ -12,7 +12,18 @@
# розпізнавання пристроїв. Тест, який не виконується, не є перевіркою; # розпізнавання пристроїв. Тест, який не виконується, не є перевіркою;
# він є її виглядом. Тому в роботі server стоїть окремий крок, який # він є її виглядом. Тому в роботі server стоїть окремий крок, який
# ПАДАЄ, якщо інтеграційні тести пропустились: без нього цей workflow # ПАДАЄ, якщо інтеграційні тести пропустились: без нього цей workflow
# повторив би ту саму помилку — зелений на тестах, яких не було. # повторив би ту саму помилку — зелений на тестах, яких не було. У
# роботі dbtest сторож свій і перевіряє інше: що міграції накотились з
# нуля, тобто база була справжня й одноразова.
#
# ЧОМУ РОБІТ З БАЗОЮ ДВІ (server і dbtest), І ЦЕ НЕ ЗАБУТИЙ ДУБЛІКАТ.
# server перевіряє КОД: збірку, vet, увесь `go test ./...`. dbtest
# перевіряє ІНСТРУМЕНТ: `scripts/dbtest.sh` — те, що людина запускає
# руками перед розгортанням. Якщо CI повторює його кроки в yaml замість
# викликати, скрипт може зогнити при зеленому конвеєрі, і виявиться це
# рівно в момент розгортання. Ціна дубля невелика: роботи йдуть по черзі
# (місткість раннера 1), кеші збірки спільні, тож повторно компілюється
# небагато — платимо часом виконання тестів, а не збіркою.
# #
# ЧОМУ ТУТ НЕМАЄ ЖОДНОЇ JS-ДІЇ (actions/checkout тощо). Раннер виконує # ЧОМУ ТУТ НЕМАЄ ЖОДНОЇ JS-ДІЇ (actions/checkout тощо). Раннер виконує
# JS-дії тим node, який знайде В ОБРАЗІ роботи. У golang:1.25-alpine # JS-дії тим node, який знайде В ОБРАЗІ роботи. У golang:1.25-alpine
@ -376,6 +387,193 @@ jobs:
if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi
if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi
# ------------------------------------------------------------------
# Тести проти бази. Рівно те, що досі запускалося руками
# (`sh scripts/dbtest.sh`), — тепер на кожен пуш.
#
# ЧОМУ ОКРЕМА РОБОТА, А НЕ КРОК У server. Вона повільна (свій
# Postgres, накат усіх міграцій з нуля) і червоніє з іншої причини:
# не «код не збирається», а «схема й код розійшлись». Змішані в одну
# роботу, ці причини читаються як одна пляма, і швидкий сигнал про
# поламану збірку чекав би на базу. 28 серпня цей скрипт за ОДИН
# прогін знайшов чотири справжні вади — зокрема nil-зріз, що їхав
# явним NULL повз DEFAULT '{}', і міграцію, якої не бачив старий
# образ. Ручний запуск найдорожчої перевірки — найгірше місце для
# ручної дії: її пропускають рівно тоді, коли поспішають.
#
# ЧОМУ ТУТ ВИКЛИКАЄТЬСЯ САМЕ СКРИПТ, А НЕ ПОВТОРЕНІ В YAML КРОКИ.
# Робота server має власну послідовність «накат + go test», і тут вона
# не дублюється заради краси: дублюється ПЕРЕВІРКА САМОГО СКРИПТА.
# `scripts/dbtest.sh` — це те, що людина запускає перед розгортанням.
# Якщо він зогниє (зміниться прапорець netpulse-migrate, поламається
# запобіжник імені, поїде тег образу), переписані в yaml кроки цього
# не помітять — і зелений CI мирно співіснуватиме зі зламаним
# скриптом. Тому виклик як є, одним рядком.
#
# ЧОМУ БАЗУ ДАЄ services:, А НЕ САМ СКРИПТ. `scripts/dbtest.sh` уміє
# підняти собі Postgres САМ — але для цього йому потрібен docker
# УСЕРЕДИНІ контейнера роботи, тобто прокинутий сокет докера хоста.
# Це рівно той дозвіл, який перетворює будь-який workflow із будь-якої
# гілки на root на цій машині, включно з даними бойової бази. Ціна вже
# заплачена один раз — для самого раннера (deploy/docker-compose.ci.yml
# називає це вголос), і роздавати її ще й КОЖНІЙ роботі не треба.
# Уникнути можна: скрипт свого контейнера не піднімає, якщо
# NETPULSE_TEST_DSN уже заданий. Тому контейнер бази створює демон
# докера ЗОВНІ роботи, а робота бачить лише хост `db` у своїй мережі.
# Docker у контейнері роботи відсутній — і це не недогляд, а те, чого
# ми домагались.
#
# ІМʼЯ БАЗИ — netpulse_probe, А НЕ netpulse_ci, ЯК У server. У скрипті
# стоїть невідмикний запобіжник: він відмовляється працювати, якщо в
# імені бази немає «probe» або «test». Перевірено фактично: з
# `netpulse_ci` він падає ще до підключення. Це не примха скрипта, а
# його головна риса — саме вона стоїть між міграціями з гілки й
# бойовою базою. Тому підлаштовується конвеєр, а не запобіжник.
# ------------------------------------------------------------------
dbtest:
runs-on: docker
timeout-minutes: 40
container:
image: golang:1.25-alpine
# Ті самі кеші, що й у server: місткість раннера — 1, тобто роботи
# йдуть по черзі й за том не бʼються. Нових томів навмисно не
# заводимо: кожне нове імʼя довелось би додавати ще й у
# container.valid_volumes (deploy/act-runner.config.yml), інакше
# робота впала б на монтуванні.
volumes:
- netpulse-ci-gomod:/go/pkg/mod
- netpulse-ci-gobuild:/root/.cache/go-build
services:
# Окремий контейнер бази, а не спільний із роботою server: роботи
# ізольовані одна від одної, і сервіс однієї для іншої не існує.
# Образ — той самий, що в docker-compose.yml і в самому скрипті.
db:
image: timescale/timescaledb:2.17.2-pg16
env:
# Користувач і пароль повторюють ті, які скрипт задає СВОЄМУ
# контейнеру. Різниця між прогоном у CI і прогоном на ноутбуці
# тоді зводиться рівно до одного: хто підняв базу.
POSTGRES_USER: netpulse
POSTGRES_PASSWORD: probe
POSTGRES_DB: netpulse_probe
TIMESCALEDB_TELEMETRY: "off"
# Без цього initdb бере кодування з локалі й цілком може
# створити базу як SQL_ASCII. netpulse-migrate це помічає і
# відмовляється працювати — задаємо одразу, як у compose.
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
# Автотюнер образу інакше візьме чверть ПАМʼЯТІ МАШИНИ під
# shared_buffers. На машині з 4 ГБ це майже гарантований OOM у
# парі з go build.
TS_TUNE_MEMORY: 512MB
TS_TUNE_NUM_CPUS: "2"
env:
# Ті самі міркування, що в роботі server: -buildvcs=false прибирає
# цілий клас відмов на правах на .git, -p=2 тримає пік памʼяті.
GOFLAGS: -buildvcs=false -p=2
GOTOOLCHAIN: local
# ЄДИНА змінна, яку читає скрипт, — і саме тому тут не може
# повторитись історія з мовчазним пропуском. Заданий DSN означає
# три речі ОДРАЗУ: скрипт не піднімає свого контейнера; міграції
# котяться на ЦЮ базу; `go test` бачить той самий DSN і не робить
# t.Skip. Розійтись цим трьом нема з чого — джерело одне. У роботі
# server джерел два (NETPULSE_DSN для міграцій, NETPULSE_TEST_DSN
# для тестів), і саме тому там потрібен окремий сторож пропуску.
NETPULSE_TEST_DSN: postgres://netpulse:probe@db:5432/netpulse_probe?sslmode=disable
steps:
- name: Інструменти
run: apk add --no-cache git
- name: Викачування
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# set -e явно, а не в надії на прапорці оболонки раннера:
# якщо git тихо не викачає дерево, наступні кроки побачать
# порожній каталог, gofmt не знайде жодного файлу, а робота
# позеленіє. Порожня перевірка виглядає так само, як успішна.
set -e
git config --global --add safe.directory '*'
git init -q .
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
git config http.extraheader "Authorization: basic $auth"
git fetch --depth=1 origin "$GITHUB_SHA" 2>/dev/null \
|| git fetch --depth=1 origin "$GITHUB_REF"
git checkout -q FETCH_HEAD
# Доказ, що дерево справді на місці, а не «команди не впали».
[ -f scripts/dbtest.sh ] || { echo "робоче дерево порожнє"; exit 1; }
# Скрипт розрахований на ГОТОВУ базу і не чекає на неї: свою він
# опитує через pg_isready, а задану ззовні вважає піднятою. Тому
# чекаємо тут. Без цього кроку перші секунди initdb давали б
# червоне «connection refused», тобто справжня вада виглядала б
# так само, як звичайний старт контейнера, — і CI навчили б
# перезапускати замість читати.
#
# Через TCP, а не pg_isready: клієнта Postgres в образі немає.
# Образ під час initdb слухає лише unix-сокет, тож відкритий 5432
# і означає «готова».
- name: Очікування бази
run: |
for i in $(seq 1 90); do
if nc -z db 5432; then echo "база слухає (${i}с)"; break; fi
if [ "$i" = "90" ]; then
echo "база не піднялась за 90 с: сервіс db мовчить на 5432."
echo "тестів проти бази НЕ БУЛО — зеленити тут нема чого."
exit 1
fi
sleep 1
done
# Вивід у файл, бо його читає наступний крок; код виходу
# зберігається окремо, інакше `cat` затер би його своїм.
- name: Тести проти бази
run: |
if sh scripts/dbtest.sh > /tmp/dbtest.log 2>&1; then
rc=0
else
rc=$?
fi
cat /tmp/dbtest.log
exit "$rc"
# Сторож. Той самий, що й у server, і з тієї ж причини: робота, яка
# існує заради тестів, мусить довести, що тести БУЛИ.
#
# Ненульовий код виходу скрипта ловить крок вище. Цей ловить
# протилежне — прогін, який «вдався», нічого не зробивши. Число
# міграцій рахується з каталогу, а не зашите, і його збіг доводить
# найбільше: база була справжня, чиста й одноразова. «схема
# актуальна» замість накату означала б базу, що пережила минулий
# прогін, тобто накат з нуля не перевірявся взагалі.
- name: Прогін проти бази справді відбувся
run: |
want=$(ls server/migrations/*.sql | wc -l | tr -d ' ')
if ! grep -q "застосовано міграцій: $want$" /tmp/dbtest.log; then
echo "не бачу накату $want міграцій на ЧИСТУ базу."
echo "порожньо — скрипт до бази не дійшов; «схема актуальна» —"
echo "база не одноразова. В обох випадках прогін нічого не довів."
grep -n 'міграц\|схема актуальна' /tmp/dbtest.log | head
exit 1
fi
if ! grep -q "усе зелене проти бази" /tmp/dbtest.log; then
echo "скрипт не дійшов до свого підсумкового рядка — обірвався"
exit 1
fi
echo "накат з нуля ($want міграцій) і тести проти живої бази виконались"
# Кеші Go ростуть необмежено. Стеля тут, у кінці роботи, а не в
# сторонньому прибиральнику: у цей момент точно ніхто не компілює,
# і чистка не може вирвати файл з-під go build.
- name: Кеші Go у межах
if: always()
run: |
bc=$(du -sm "$(go env GOCACHE)" 2>/dev/null | cut -f1); bc=${bc:-0}
mc=$(du -sm "$(go env GOMODCACHE)" 2>/dev/null | cut -f1); mc=${mc:-0}
echo "кеш збірки: ${bc} МБ (стеля 1200), модулі: ${mc} МБ (стеля 900)"
if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; fi
if [ "$mc" -gt 900 ]; then echo "чищу кеш модулів"; go clean -modcache; fi
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Зонд. Без бази: він до неї не ходить. # Зонд. Без бази: він до неї не ходить.
# ------------------------------------------------------------------ # ------------------------------------------------------------------

5
.gitignore vendored
View file

@ -33,6 +33,11 @@
/data/ /data/
/var/ /var/
# Дерево попередньої версії, яке розгортає `./netpulse sandbox upgrade`.
# Це повний другий примірник вихідних текстів із git archive — комітити
# його означало б покласти історію проєкту всередину самої історії.
/.sandbox-prev/
# Фронтенд # Фронтенд
/web/node_modules/ /web/node_modules/
/web/dist/ /web/dist/

View file

@ -7683,3 +7683,136 @@ NULL і валить запис. Правило без власних канал
однієї сходинки за такт. однієї сходинки за такт.
Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**. Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**.
---
## 2026-08-28 — Тести проти бази більше не запускає людина
`scripts/dbtest.sh` існував рівно один день і за цей день виявився
найрезультативнішою перевіркою проєкту: чотири справжні вади за один
прогін, включно з `nil`-зрізом, що їхав явним `NULL` повз `DEFAULT '{}'`,
і міграцією, якої не бачив старий образ. І весь цей день його запускав я
руками. Найдорожча перевірка на ручному приводі — це та сама вада, від
якої скрипт і написано, тільки на поверх вище: її пропускають рівно тоді,
коли поспішають, тобто перед розгортанням.
Тепер у `.forgejo/workflows/ci.yml` є окрема робота `dbtest`.
**Чому окрема, а не крок у `server`.** Вона повільна й червоніє з іншої
причини: не «код не збирається», а «схема й код розійшлись». Змішані в
одну роботу, ці причини читаються як одна пляма, а швидкий сигнал про
поламану збірку чекав би на базу.
**Чому вона викликає скрипт, а не повторює його кроки в yaml.** Робота
`server` свою послідовність «накат + `go test`» уже має, і дубль тут
свідомий: дублюється не перевірка коду, а перевірка ІНСТРУМЕНТА.
`dbtest.sh` — це те, що запускає людина перед розгортанням; переписані в
yaml кроки не помітять, як він зогнив, і зелений конвеєр мирно
співіснуватиме зі зламаним скриптом. Дубль дешевий: роботи йдуть по
черзі (місткість раннера 1), кеш збірки спільний.
**Головне, що довелось вирішити чесно: звідки береться Postgres.**
Скрипт уміє підняти його сам — але для цього йому потрібен docker
УСЕРЕДИНІ контейнера роботи, тобто прокинутий сокет докера хоста. Це
рівно той дозвіл, який перетворює будь-який workflow із будь-якої гілки
на root на цій машині, включно з даними бойової бази. Один раз ця ціна
вже заплачена — за сам раннер, і `deploy/docker-compose.ci.yml` називає
її вголос. Роздавати її ще й кожній роботі не треба, і не довелось: у
скрипта є друга гілка, «готова база», і саме вона тут використана. Базу
дає `services:`, контейнер створює демон докера ЗОВНІ роботи, а робота
бачить лише хост `db` у своїй мережі. Докера в ній немає.
**Запобіжник імені виявився не формальністю.** Скрипт відмовляється
працювати, якщо в імені бази немає `probe` або `test`. У роботі `server`
база зветься `netpulse_ci` — перевірено фактично: з нею скрипт падає ще
до підключення. Тому в `dbtest` своя база, `netpulse_probe`. Підлаштовано
конвеєр, а не запобіжник: він стоїть між міграціями з гілки й бойовою
базою, і послаблювати його заради зручності CI означало б міняти єдину
справжню гарантію на косметику.
**Що станеться, якщо база не піднялась.** Це найважливіше питання до
цілої роботи, бо саме тут повторити початкову ваду найлегше. Відповідь:
робота ПАДАЄ, і падає тричі поспіль на різних рубежах. Крок «Очікування
бази» опитує 5432 дев'яносто секунд і виходить із текстом «тестів проти
бази НЕ БУЛО». Далі сам скрипт: DSN заданий, отже свого контейнера він не
піднімає й одразу котить міграції — а `netpulse-migrate` без бази
повертає ненульовий код (перевірено локально: `rc=1`). І нарешті сторож
«Прогін проти бази справді відбувся» вимагає в логу накат саме тієї
кількості міграцій, що лежить у каталозі: порожній лог означає, що скрипт
до бази не дійшов, а «схема актуальна» — що база пережила минулий прогін
і накат з нуля не перевірявся.
Мовчазного пропуску, як у `grpcapi`, тут не може бути з іншої причини,
структурної: `NETPULSE_TEST_DSN` — ЄДИНА змінна, яку читає скрипт. Той
самий рядок котить міграції й доїжджає до `go test`. У роботі `server`
джерел два (`NETPULSE_DSN` і `NETPULSE_TEST_DSN`), і рівно тому там
потрібен окремий сторож пропуску — розійтись двом джерелам є з чого.
**Чого не перевірено.** Того, що цей раннер узагалі виконує `services:`.
Робота `server` покладається на них теж, але `ci.yml` не запускався
жодного разу — раннер стоїть, конвеєр на ньому не бігав. Локально
перевірено все, що можна без раннера: YAML розбирається, кожен `run`
проходить `sh -n`, скрипт із бойовим іменем бази відмовляється працювати,
з `netpulse_ci` — теж, з `netpulse_probe` і недосяжною базою повертає 1,
а тіло сторожа, витягнуте прямо з `ci.yml`, дає правильний вердикт на
чотирьох варіантах логу. Зеленого наскрізного прогону не було: докера на
цій машині немає.
---
## 2026-08-28 — Відкат уперше дійшов до живого заліза. І показав пароль
Механізм відкату конфігурації існував із 0035 і НІКОЛИ не виконувався на
справжньому обладнанні. Сьогодні його прогнали до самої межі — плану,
заявки й погодження — і на залізо свідомо НЕ писали.
### Три відмови, і кожна правильна
* **ZTE OLT, усі 4 наявні версії:** «різниця завелика для автоматичного
відкату: версії розійшлися майже повністю». Для цього пристрою архів
для порівняння практично непридатний — щозбору конфіг інший цілком.
* **D-Link ПОЛЬОВЕ-СВ і ПОЛЬОВЕ.7:** план пройшов, заявка відмовилась —
«312 рядків неможливо прибрати автоматично» (на другому 671). Профіль
чесно пояснює причину: D-Link не має універсального заперечення рядка.
* **Погодити відкат у цьому кабінеті неможливо в принципі:**
`require_approval: true`, `allow_self_approve: false`, користувач один.
Це не вада коду — це зіткнення правила безпеки з реальністю, і воно
стосується КОЖНОГО нового клієнта: усі починають самі.
### Вада, яку відмова випадково прикрила
Перший рядок збереженого конфігу — `Command: show config current_config`,
тобто відлуння команди, а не конфігурація. Планувальник порахував його
рядком конфігу й поклав у команди до заливки `Command: show config`. Якби
312 рядків не зупинили заявку, на живий комутатор пішла б безглузда
команда. Врятувала чужа перевірка, не своя.
### І головне: пароль адміністратора лежав відкритим
У конфігу ПОЛЬОВЕ-СВ:
```
create account admin btadmin
<пароль>
<пароль ще раз>
disable password encryption
```
Комутатор питає пароль ІНТЕРАКТИВНО, і `show config` віддає відповіді
окремими рядками — без жодного ключового слова поруч. `redactLines`
маскує порядково за зразками (`password`, `community`, `secret`…), тож
такий рядок для нього — просто слово. Пароль опинявся в плані відкату, у
`ncm.rollbacks` і — оскільки маскування перед записом у git НЕМАЄ ВЗАГАЛІ
у git-дзеркалі, тобто за межами сервера.
**Виправлено:** `redactLines` отримав стан. Після рядка, що заводить
обліковий запис, наступні до двох односкладових рядків маскуються як
відлуння пароля. Ім'я облікового запису лишається читабельним — людина
при погодженні мусить бачити, ЩО змінюється. Односкладовий рядок сам по
собі не маскується (`enable`, `end`, `exit` не постраждали) — тест на це є.
**Що НЕ виправлено й потребує рішення власника:** тіла конфігів ідуть у
git-дзеркало ДОСЛІВНО. Маскувати їх означає зламати відновлення з архіву
(NCM цінний саме побайтовою точністю); не маскувати — означає, що
кожен секрет із конфігу виїжджає на зовнішній git-сервер. Це вибір, а не
вада, і зробити його має власник.

View file

@ -520,10 +520,14 @@ CI-раннер ми запустили 2026-08-27. Бракує лише тог
### Друге: перевірки, які є, але не бігають самі ### Друге: перевірки, які є, але не бігають самі
4. **`scripts/dbtest.sh` не в CI.** Раннер уже стоїть (контейнер 4. **`scripts/dbtest.sh` заведено в CI, але на раннері ще не прогнано.**
`netpulse-ci-runner`), `check.sh` у конвеєрі є, `build.py --check` У `.forgejo/workflows/ci.yml` зʼявилась окрема робота `dbtest`: базу
теж. Бракує саме тестів проти бази — тобто тих, що 28 серпня знайшли дає `services:` (докер-сокет усередину роботи не прокидається), DSN
чотири справжні вади за один прогін. Зараз їх запускаю руками я. вказує на `netpulse_probe`, сторож вимагає накату всіх міграцій з
нуля. Перевірено локально — YAML, синтаксис кроків і поведінка
скрипта з заданим DSN; не перевірено головного: що цей раннер
виконує `services:`. Робота `server` користується ним теж, але
workflow не запускався жодного разу.
5. **Токен у `origin` замість ключа розгортання.** 5. **Токен у `origin` замість ключа розгортання.**
### Третє: діри, названі рецензіями й не закриті ### Третє: діри, названі рецензіями й не закриті

View file

@ -25,7 +25,9 @@ runner:
# Стеля на роботу. Довший прогін означає, що щось зависло (мережа до # Стеля на роботу. Довший прогін означає, що щось зависло (мережа до
# реєстру образів, база, яка не піднялась), а не що збірка велика: # реєстру образів, база, яка не піднялась), а не що збірка велика:
# найдовша робота тут — server, і вона вкладається в десяток хвилин. # найдовші роботи тут — server і dbtest, і кожна вкладається в десяток
# хвилин. Свої стелі вони мають ще й у самому workflow
# (timeout-minutes: 40) — ця лишається запасною.
timeout: 1h timeout: 1h
# Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше # Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше

View file

@ -49,8 +49,9 @@
# Памʼять у спокої: раннер ~40 МБ + прибиральник ~5 МБ. # Памʼять у спокої: раннер ~40 МБ + прибиральник ~5 МБ.
# Памʼять під час прогону: одна робота за раз (capacity: 1), стеля # Памʼять під час прогону: одна робота за раз (capacity: 1), стеля
# контейнера роботи — 1600 МБ (deploy/act-runner.config.yml), плюс # контейнера роботи — 1600 МБ (deploy/act-runner.config.yml), плюс
# контейнер Postgres у роботі server (~400 МБ із TS_TUNE_MEMORY). # контейнер Postgres у роботах server і dbtest (~400 МБ із
# Тобто пік ~2 ГБ понад те, що вже їсть бойовий стек. # TS_TUNE_MEMORY). Роботи з базою дві, але йдуть вони по черзі, тож
# пік від цього не росте: ~2 ГБ понад те, що вже їсть бойовий стек.
# Диск: образи ~1.1 ГБ (alpine 8 МБ, node:22-alpine ~180 МБ, # Диск: образи ~1.1 ГБ (alpine 8 МБ, node:22-alpine ~180 МБ,
# golang:1.25-alpine ~350 МБ, timescaledb ~450 МБ, сам раннер # golang:1.25-alpine ~350 МБ, timescaledb ~450 МБ, сам раннер
# ~120 МБ) + кеші зі стелями 1200/900/400 МБ, які тримають самі # ~120 МБ) + кеші зі стелями 1200/900/400 МБ, які тримають самі

976
netpulse

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,14 @@
# NETPULSE_TEST_DSN=... scripts/dbtest.sh # проти готової бази, # NETPULSE_TEST_DSN=... scripts/dbtest.sh # проти готової бази,
# # ім'я якої містить probe/test # # ім'я якої містить probe/test
# #
# У CI ЙДЕ ДРУГИЙ ШЛЯХ. Робота dbtest у .forgejo/workflows/ci.yml задає
# NETPULSE_TEST_DSN на базу netpulse_probe, яку раннер піднімає сервісом.
# Саме тому там і потрібна гілка «готова база»: підняти контейнер собі
# скрипт зміг би лише з докер-сокетом хоста всередині роботи, а це
# root-доступ до всієї машини для будь-якого workflow із будь-якої гілки.
# Другий наслідок цієї гілки — ім'я бази в CI мусить містити probe/test,
# інакше запобіжник нижче спрацює на власному конвеєрі.
#
set -u set -u
SRC="$(cd "$(dirname "$0")/.." && pwd)" SRC="$(cd "$(dirname "$0")/.." && pwd)"

View file

@ -0,0 +1,73 @@
package store
import (
"strings"
"testing"
)
// Точний уривок із конфігу живого комутатора D-Link DGS-1100-06/ME,
// через який вада й знайшлась: пароль адміністратора лежав відкритим у
// плані відкату, у базі й у git-дзеркалі.
//
// Пароль тут вигаданий; форма — справжня.
func TestAccountPasswordEchoIsMasked(t *testing.T) {
in := []string{
"# User Account",
"create account admin btadmin ",
"s3cr3tpass",
"s3cr3tpass",
"disable password encryption",
}
got := redactLines(nil, in)
for i, line := range got {
if strings.Contains(line, "s3cr3tpass") {
t.Errorf("рядок %d лишив пароль відкритим: %q", i, line)
}
}
if got[2] != "***" || got[3] != "***" {
t.Errorf("відлуння пароля не замасковане: %q, %q", got[2], got[3])
}
// Рядок про сам обліковий запис має лишитись читабельним: людина при
// погодженні мусить бачити, ЩО саме змінюється.
if !strings.Contains(got[1], "btadmin") {
t.Errorf("з'їдено ім'я облікового запису: %q", got[1])
}
}
// Маскування не має пожирати справжні рядки конфігу.
func TestAccountEchoDoesNotEatConfig(t *testing.T) {
in := []string{
"create account user guest ",
"pass1",
"pass1",
"enable", // односкладова команда одразу після відлуння
"disable clipaging", // звичайний рядок
}
got := redactLines(nil, in)
if got[3] != "enable" {
t.Errorf("з'їдено команду після двох рядків відлуння: %q", got[3])
}
if got[4] != "disable clipaging" {
t.Errorf("з'їдено звичайний рядок: %q", got[4])
}
}
// Односкладовий рядок САМ ПО СОБІ не секрет: маскується лише той, що
// стоїть одразу після заведення облікового запису.
func TestBareTokenAloneIsNotMasked(t *testing.T) {
got := redactLines(nil, []string{"enable", "end", "exit"})
for i, line := range got {
if line == "***" {
t.Errorf("рядок %d замасковано без причини: був %q", i, []string{"enable", "end", "exit"}[i])
}
}
}
// Стара поведінка не зламана: секрет в одному рядку з ключовим словом.
func TestInlineSecretStillMasked(t *testing.T) {
got := redactLines(nil, []string{"snmp-server community public RO"})
if strings.Contains(got[0], "public") {
t.Errorf("community лишилась відкритою: %q", got[0])
}
}

View file

@ -461,15 +461,62 @@ func redactLines(patterns []string, in []string) []string {
res = append(res, defaultRedact...) res = append(res, defaultRedact...)
out := make([]string, len(in)) out := make([]string, len(in))
// echo — скільки наступних рядків є відлунням запиту пароля.
//
// Порядкове маскування ловить лише те, де секрет стоїть в одному
// рядку з ключовим словом. У D-Link це не так: комутатор питає
// пароль інтерактивно, і `show config` віддає ВІДПОВІДІ окремими
// рядками, без жодного слова поруч:
//
// create account admin btadmin
// <пароль>
// <пароль ще раз>
//
// Такий рядок — просто слово. Жоден зразок його не впізнає, і саме
// так пароль адміністратора живого комутатора опинився відкритим у
// плані відкату. Тому стан: після рядка, що заводить обліковий
// запис, наступні односкладові рядки — це секрет, поки не почнеться
// щось, схоже на команду.
echo := 0
for i, s := range in { for i, s := range in {
if echo > 0 && isBareToken(s) {
out[i] = "***"
echo--
continue
}
echo = 0
for _, re := range res { for _, re := range res {
s = re.ReplaceAllString(s, "***") s = re.ReplaceAllString(s, "***")
} }
if accountOpener.MatchString(s) {
// Двох вистачає: пароль і його підтвердження. Більше —
// означало б з'їдати справжні рядки конфігу.
echo = 2
}
out[i] = s out[i] = s
} }
return out return out
} }
// accountOpener — рядок, після якого пристрій питає пароль окремо.
//
// Свідомо ширший за D-Link: та сама звичка є в кількох клонів, а ціна
// зайвого маскування — «***» замість слова в плані, тоді як ціна
// пропуску — пароль адміністратора в базі, у git-дзеркалі й на екрані.
var accountOpener = regexp.MustCompile(`(?i)^[ ]*(create|config)[ ]+account($|[^a-zA-Z0-9_-])`)
// isBareToken — чи схожий рядок на відлуння введеного пароля.
//
// Одне слово без пробілів і без початкового `#`: команди конфігурації
// односкладовими не бувають, а коментар починається з решітки.
func isBareToken(s string) bool {
t := strings.TrimSpace(s)
if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, "!") {
return false
}
return !strings.ContainsAny(t, " \t")
}
// redactText — те саме для суцільного тексту (стенограма сесії). // redactText — те саме для суцільного тексту (стенограма сесії).
func redactText(patterns []string, in string) string { func redactText(patterns []string, in string) string {
if in == "" { if in == "" {