Всяко приложение има .env файл с пароли за базата, API ключове, SMTP пароли, OAuth secrets. При тринайсет приложения и три сървъра това са над петдесет такива файла. Въпросът е къде живеят, така че изгубен сървър или изгубен лаптоп да не е катастрофа, и така, че никога да не комитна парола по погрешка.
Това е вторият пост от поредицата за infra репото. Тук е отговорът, който избрах: тайните са в git, криптирани, със SOPS и age.
Как беше преди
Честно: .env.prod в частния repo на всяко приложение, а понякога и вътре в Docker образа. Работи, докато не се наложи да смениш парола и се чудиш в кои от шестте места е. И докато в историята на git не остане един GitHub token, копиран в Dockerfile-а на всяко приложение преди години. Частен repo или не, историята не се трие.
Гледах и vault услуги. Google Secret Manager отпадна по принцип (нищо, което работи само в Google). HashiCorp Vault или Infisical са още една услуга, която трябва да се инсталира, пази, обновява и бекъпва, за да пази петдесет малки файла. Прекалено много грижи за едно толкова просто нещо.
Какво прави SOPS
SOPS криптира стойностите във файла, не целия файл. Един .env в репото изглежда така:
DB_HOST=ENC[AES256_GCM,data:mQ3f…,iv:…,tag:…,type:str]
DB_PASS=ENC[AES256_GCM,data:7Kq2…,iv:…,tag:…,type:str]
SMTP_PASS=ENC[AES256_GCM,data:Zp9c…,iv:…,tag:…,type:str]
ADSENSE_CLIENT=
Имената се четат, стойностите не. git diff показва кой ключ е сменен, без да показва на какво. (Празна стойност си остава празна: вижда се и кое още не е попълнено.)
Всеки файл има свой ключ за данните, който е увит за всеки получател от .sops.yaml. Получателите са age ключове: age е малък, модерен инструмент за криптиране, без GPG, без ключодържатели, една команда прави ключ. Един ред в .sops.yaml казва кои файлове са тайни и кой може да ги чете:
creation_rules:
- path_regex: (^|/)((app\.)?secrets\.env|secrets/[^/]+)$
age: age1…, age1…, age1…
Правилата
Инструментът е bash скрипт от петстотин реда, secrets.sh, с още толкова тестове (временни ключове, фалшив сървър). Правилата, които той налага, са по-важни от кода:
- Сървърите никога не получават ключ. Разкриптирането става само на моя компютър. Една команда разкриптира един файл и го праща по SSH на неговия сървър, mode 600, с правилния собственик. Пробит сървър вижда своите тайни (те са му нужни), не тези на другите.
- Нищо не печата стойност.
diffпоказва имена на ключове и+ - ~; за двоични файлове само „еднакъв“ / „различен“. Няма как парола да попадне в терминал, лог или история на шела. - Некриптиран
.envне може да се комитне. Git hook проверява всеки staged файл с име*.envили в папкаsecrets/; CI проверява същото, ако някой е прескочил hook-а. Пет минути работа, спира най-глупавата грешка завинаги. - Едно нещо остава на сървъра:
TAG, версията на образа, която деплоят сменя. Репото не я пази,pushне я пипа.
Коя тайна къде живее е една таблица: име, файл в репото, сървър, път, собственик, формат, и командата, която прилага промяната:
blog_kolibg_com-app frontend/apps/blog_kolibg_com/app.secrets.env root@188.… /home/izopi4a/docker/blog_kolibg_com/app.env izopi4a dotenv - cd … && docker compose up -d
Ден за ден
Смяна на парола:
scripts/secrets.sh edit blog_kolibg_com-app # отваря разкриптирания файл в редактора
scripts/secrets.sh push blog_kolibg_com-app # показва ~ SMTP_PASS, пита y/N, пише на сървъра
ssh … 'cd ~/docker/blog_kolibg_com && docker compose up -d'
git commit -am "Secrets: blog, new SMTP password"
push никога не предполага, че репото и сървърът са еднакви: показва какво ще се промени, преди да пише, и пази старата версия до файла. Ако някой е пипал файла на ръка на сървъра, diff го показва, а pull -f го прибира в репото.
Нов компютър: един скрипт клонира всички репота, прави age ключ, добавя го към получателите с ключа за възстановяване, записва всички dev .env файлове. Половин час, вместо половин ден по стари лаптопи.
Уеб интерфейсът
Терминалът е добър за смяна на една парола. За „какво има в тайните на това приложение“ и „кое не е комитнато“ исках страница. Затова има и малка локална уеб програма, написана с AI (Go, около хиляда реда), която се пуска с една команда и отваря браузъра:
scripts/secrets-ui.sh
Вляво всяка конфигурация от таблицата, групирана по приложение, с точка до тези, които са променени и не са комитнати. Вдясно ключовете, със скрити стойности: Покажи (скрива се пак след 30 секунди), Копирай, Редактирай, Добави, Изтрий. Всяка промяна се записва криптирана в репото веднага. Бутоните Сравни със сървъра, Изпрати, Прибери и Commit викат същия bash скрипт отдолу, така че логиката за push и pull е на едно място. Има и Тествай ключа за възстановяване: поставяш го от мениджъра на пароли, програмата проверява, че той сам разкриптира всеки файл, и го забравя.
Има и една особеност, която първия път прилича на бъг: страницата не се презарежда. Натиснеш ли F5, вместо нея виждаш „отвори линка, който скриптът отпечата“. Не е бъг, а нарочно. Програмата слуша само на 127.0.0.1, а линкът, който отпечатва, носи случаен token. Страницата го прочита, веднага го маха от адресния ред и го пази само в паметта си, като го праща като header при всяка заявка. Така token-ът не остава в историята на браузъра, в bookmark или на снимка на екрана, а друг сайт, отворен в същия браузър, не може да извика нищо от API-то, защото няма token. Цената е, че презареждането е нова сесия: Ctrl+C в терминала, пак командата, нов линк, нов token. За нещо, което показва пароли, размяната ми се струва правилна.
Изгубен лаптоп
Тук е частта, която повечето хора пропускат. Получателите са ключовете на моите компютри плюс един ключ за възстановяване, който живее само в мениджъра на пароли (генериран в терминала, никога записан на диск). Ако компютърът ми изчезне: махам неговия ред от .sops.yaml и пускам две команди, не една. updatekeys преувива съществуващия ключ за данните за новия списък, но изгубеният лаптоп още може да го разгъне от която и да е стара версия в git. Затова и rotate: нов ключ за данните на всеки файл. След двете новите версии са нечетими за него.
Старите версии в историята си остават четими с изгубения ключ. Това не се оправя с криптография, а с ротация: нови пароли за базите, нови API ключове, нови OAuth secrets. Скучно, но е списък, и списъкът е в репото.
Ключовете, които никога не се ротират
Не всичко в тайните е парола. Някои ключове криптират данни: ключът за прикачените файлове на една услуга, ключът за документите на друга, GPG ключът, с който е подписано APT хранилището, keystore-ът на Android приложение. Нов ключ там не е „смяна на парола“, а загуба на всичко старо. Тези са в репото точно за да не се изгубят, и са изрично извадени от всяка ротация. Ако някога трябва да се сменят, това е миграция в приложението, не push.
Какво получих
История на всяка тайна (git log на файла). Отговор на „къде е паролата“: винаги един. Сигурност, че файлът на сървъра е точно този в репото (diff). Нов компютър за минути. И нула тайни в репотата на приложенията: те четат само средата си.
Целият инструмент беше написан от AI за една вечер, с тестовете, заедно с миграцията на всеки файл по име. Моята част беше да кажа правилата по-горе и да проверя, че скриптът наистина никога не печата стойност. Това последното го проверих два пъти.
Следващият пост: самата инфраструктура, сървър по сървър.


Коментари