Відкат на живому залізі: пароль адміністратора лежав відкритим
Механізм відкату існував з 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.
|
# Складання і перевірки 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
5
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
133
HISTORY.md
133
HISTORY.md
|
|
@ -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-сервер. Це вибір, а не
|
||||||
|
вада, і зробити його має власник.
|
||||||
|
|
|
||||||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -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` замість ключа розгортання.**
|
||||||
|
|
||||||
### Третє: діри, названі рецензіями й не закриті
|
### Третє: діри, названі рецензіями й не закриті
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ runner:
|
||||||
|
|
||||||
# Стеля на роботу. Довший прогін означає, що щось зависло (мережа до
|
# Стеля на роботу. Довший прогін означає, що щось зависло (мережа до
|
||||||
# реєстру образів, база, яка не піднялась), а не що збірка велика:
|
# реєстру образів, база, яка не піднялась), а не що збірка велика:
|
||||||
# найдовша робота тут — server, і вона вкладається в десяток хвилин.
|
# найдовші роботи тут — server і dbtest, і кожна вкладається в десяток
|
||||||
|
# хвилин. Свої стелі вони мають ще й у самому workflow
|
||||||
|
# (timeout-minutes: 40) — ця лишається запасною.
|
||||||
timeout: 1h
|
timeout: 1h
|
||||||
|
|
||||||
# Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше
|
# Пауза між опитуваннями Forgejo. Дві секунди — замовчування; частіше
|
||||||
|
|
|
||||||
|
|
@ -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 МБ, які тримають самі
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
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...)
|
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 == "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue