Відкат на живому залізі: пароль адміністратора лежав відкритим
Механізм відкату існував з 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:
parent
6d1786e111
commit
c83324ae3e
11 changed files with 1449 additions and 37 deletions
15
.dockerignore
Normal file
15
.dockerignore
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Що НЕ потрапляє в контекст збірки.
|
||||
#
|
||||
# Файл з'явився через пісочницю оновлення: вона розгортає повне дерево
|
||||
# попередньої версії в .sandbox-prev/, і без цього рядка кожен
|
||||
# `docker compose build` тягнув би подвійні вихідні тексти разом зі
|
||||
# зібраним веб-інтерфейсом. Помітно це не як помилка, а як «збірка чомусь
|
||||
# стала вдвічі довшою».
|
||||
.sandbox-prev/
|
||||
|
||||
# Секрети. У Dockerfile їх не копіює ніхто, але контекст іде демонові
|
||||
# цілком — тобто файл із паролями бази й ключем шифрування подорожує
|
||||
# туди без потреби. Ціна виключення нульова, ціна недогляду — ні.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
# Складання і перевірки NetPulse на Forgejo Actions.
|
||||
#
|
||||
# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`),
|
||||
# плюс три речі, яких скрипт зробити не може: інтеграційні тести проти
|
||||
# живої бази, звірка каталогу профілів зі згенерованою міграцією і
|
||||
# перевірка закінчень рядків.
|
||||
# Проганяється рівно те, що досі проганяли руками (`sh scripts/check.sh`
|
||||
# і `sh scripts/dbtest.sh`), плюс три речі, яких перший скрипт зробити не
|
||||
# може: інтеграційні тести проти живої бази, звірка каталогу профілів зі
|
||||
# згенерованою міграцією і перевірка закінчень рядків.
|
||||
#
|
||||
# ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА САМІ ТЕСТИ. Інтеграційні тести grpcapi мовчки
|
||||
# пропускались півтора року: без NETPULSE_TEST_DSN вони роблять t.Skip,
|
||||
|
|
@ -12,7 +12,18 @@
|
|||
# розпізнавання пристроїв. Тест, який не виконується, не є перевіркою;
|
||||
# він є її виглядом. Тому в роботі server стоїть окремий крок, який
|
||||
# ПАДАЄ, якщо інтеграційні тести пропустились: без нього цей workflow
|
||||
# повторив би ту саму помилку — зелений на тестах, яких не було.
|
||||
# повторив би ту саму помилку — зелений на тестах, яких не було. У
|
||||
# роботі dbtest сторож свій і перевіряє інше: що міграції накотились з
|
||||
# нуля, тобто база була справжня й одноразова.
|
||||
#
|
||||
# ЧОМУ РОБІТ З БАЗОЮ ДВІ (server і dbtest), І ЦЕ НЕ ЗАБУТИЙ ДУБЛІКАТ.
|
||||
# server перевіряє КОД: збірку, vet, увесь `go test ./...`. dbtest
|
||||
# перевіряє ІНСТРУМЕНТ: `scripts/dbtest.sh` — те, що людина запускає
|
||||
# руками перед розгортанням. Якщо CI повторює його кроки в yaml замість
|
||||
# викликати, скрипт може зогнити при зеленому конвеєрі, і виявиться це
|
||||
# рівно в момент розгортання. Ціна дубля невелика: роботи йдуть по черзі
|
||||
# (місткість раннера 1), кеші збірки спільні, тож повторно компілюється
|
||||
# небагато — платимо часом виконання тестів, а не збіркою.
|
||||
#
|
||||
# ЧОМУ ТУТ НЕМАЄ ЖОДНОЇ JS-ДІЇ (actions/checkout тощо). Раннер виконує
|
||||
# JS-дії тим node, який знайде В ОБРАЗІ роботи. У golang:1.25-alpine
|
||||
|
|
@ -376,6 +387,193 @@ jobs:
|
|||
if [ "$bc" -gt 1200 ]; then echo "чищу кеш збірки"; go clean -cache; 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
5
.gitignore
vendored
|
|
@ -33,6 +33,11 @@
|
|||
/data/
|
||||
/var/
|
||||
|
||||
# Дерево попередньої версії, яке розгортає `./netpulse sandbox upgrade`.
|
||||
# Це повний другий примірник вихідних текстів із git archive — комітити
|
||||
# його означало б покласти історію проєкту всередину самої історії.
|
||||
/.sandbox-prev/
|
||||
|
||||
# Фронтенд
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
|
|
|
|||
133
HISTORY.md
133
HISTORY.md
|
|
@ -7683,3 +7683,136 @@ NULL і валить запис. Правило без власних канал
|
|||
однієї сходинки за такт.
|
||||
|
||||
Прогнано проти справжньої бази: **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-сервер. Це вибір, а не
|
||||
вада, і зробити його має власник.
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -520,10 +520,14 @@ CI-раннер ми запустили 2026-08-27. Бракує лише тог
|
|||
|
||||
### Друге: перевірки, які є, але не бігають самі
|
||||
|
||||
4. **`scripts/dbtest.sh` не в CI.** Раннер уже стоїть (контейнер
|
||||
`netpulse-ci-runner`), `check.sh` у конвеєрі є, `build.py --check`
|
||||
теж. Бракує саме тестів проти бази — тобто тих, що 28 серпня знайшли
|
||||
чотири справжні вади за один прогін. Зараз їх запускаю руками я.
|
||||
4. **`scripts/dbtest.sh` заведено в CI, але на раннері ще не прогнано.**
|
||||
У `.forgejo/workflows/ci.yml` зʼявилась окрема робота `dbtest`: базу
|
||||
дає `services:` (докер-сокет усередину роботи не прокидається), DSN
|
||||
вказує на `netpulse_probe`, сторож вимагає накату всіх міграцій з
|
||||
нуля. Перевірено локально — YAML, синтаксис кроків і поведінка
|
||||
скрипта з заданим DSN; не перевірено головного: що цей раннер
|
||||
виконує `services:`. Робота `server` користується ним теж, але
|
||||
workflow не запускався жодного разу.
|
||||
5. **Токен у `origin` замість ключа розгортання.**
|
||||
|
||||
### Третє: діри, названі рецензіями й не закриті
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ runner:
|
|||
|
||||
# Стеля на роботу. Довший прогін означає, що щось зависло (мережа до
|
||||
# реєстру образів, база, яка не піднялась), а не що збірка велика:
|
||||
# найдовша робота тут — server, і вона вкладається в десяток хвилин.
|
||||
# найдовші роботи тут — server і dbtest, і кожна вкладається в десяток
|
||||
# хвилин. Свої стелі вони мають ще й у самому workflow
|
||||
# (timeout-minutes: 40) — ця лишається запасною.
|
||||
timeout: 1h
|
||||
|
||||
# Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше
|
||||
|
|
|
|||
|
|
@ -49,8 +49,9 @@
|
|||
# Памʼять у спокої: раннер ~40 МБ + прибиральник ~5 МБ.
|
||||
# Памʼять під час прогону: одна робота за раз (capacity: 1), стеля
|
||||
# контейнера роботи — 1600 МБ (deploy/act-runner.config.yml), плюс
|
||||
# контейнер Postgres у роботі server (~400 МБ із TS_TUNE_MEMORY).
|
||||
# Тобто пік ~2 ГБ понад те, що вже їсть бойовий стек.
|
||||
# контейнер Postgres у роботах server і dbtest (~400 МБ із
|
||||
# TS_TUNE_MEMORY). Роботи з базою дві, але йдуть вони по черзі, тож
|
||||
# пік від цього не росте: ~2 ГБ понад те, що вже їсть бойовий стек.
|
||||
# Диск: образи ~1.1 ГБ (alpine 8 МБ, node:22-alpine ~180 МБ,
|
||||
# golang:1.25-alpine ~350 МБ, timescaledb ~450 МБ, сам раннер
|
||||
# ~120 МБ) + кеші зі стелями 1200/900/400 МБ, які тримають самі
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@
|
|||
# NETPULSE_TEST_DSN=... scripts/dbtest.sh # проти готової бази,
|
||||
# # ім'я якої містить probe/test
|
||||
#
|
||||
# У CI ЙДЕ ДРУГИЙ ШЛЯХ. Робота dbtest у .forgejo/workflows/ci.yml задає
|
||||
# NETPULSE_TEST_DSN на базу netpulse_probe, яку раннер піднімає сервісом.
|
||||
# Саме тому там і потрібна гілка «готова база»: підняти контейнер собі
|
||||
# скрипт зміг би лише з докер-сокетом хоста всередині роботи, а це
|
||||
# root-доступ до всієї машини для будь-якого workflow із будь-якої гілки.
|
||||
# Другий наслідок цієї гілки — ім'я бази в CI мусить містити probe/test,
|
||||
# інакше запобіжник нижче спрацює на власному конвеєрі.
|
||||
#
|
||||
set -u
|
||||
|
||||
SRC="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
|
|
|||
73
server/internal/store/ncm_redact_account_test.go
Normal file
73
server/internal/store/ncm_redact_account_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -461,15 +461,62 @@ func redactLines(patterns []string, in []string) []string {
|
|||
res = append(res, defaultRedact...)
|
||||
|
||||
out := make([]string, len(in))
|
||||
// echo — скільки наступних рядків є відлунням запиту пароля.
|
||||
//
|
||||
// Порядкове маскування ловить лише те, де секрет стоїть в одному
|
||||
// рядку з ключовим словом. У D-Link це не так: комутатор питає
|
||||
// пароль інтерактивно, і `show config` віддає ВІДПОВІДІ окремими
|
||||
// рядками, без жодного слова поруч:
|
||||
//
|
||||
// create account admin btadmin
|
||||
// <пароль>
|
||||
// <пароль ще раз>
|
||||
//
|
||||
// Такий рядок — просто слово. Жоден зразок його не впізнає, і саме
|
||||
// так пароль адміністратора живого комутатора опинився відкритим у
|
||||
// плані відкату. Тому стан: після рядка, що заводить обліковий
|
||||
// запис, наступні односкладові рядки — це секрет, поки не почнеться
|
||||
// щось, схоже на команду.
|
||||
echo := 0
|
||||
for i, s := range in {
|
||||
if echo > 0 && isBareToken(s) {
|
||||
out[i] = "***"
|
||||
echo--
|
||||
continue
|
||||
}
|
||||
echo = 0
|
||||
for _, re := range res {
|
||||
s = re.ReplaceAllString(s, "***")
|
||||
}
|
||||
if accountOpener.MatchString(s) {
|
||||
// Двох вистачає: пароль і його підтвердження. Більше —
|
||||
// означало б з'їдати справжні рядки конфігу.
|
||||
echo = 2
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
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 — те саме для суцільного тексту (стенограма сесії).
|
||||
func redactText(patterns []string, in string) string {
|
||||
if in == "" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue