Уеб AI · 8 мин четене

Тайните в git: SOPS + age

Над петдесет .env файла, криптирани в репото: сървърите никога не виждат ключ, нищо не печата парола, git hook спира грешния commit. Как работи и какво става при изгубен лаптоп.

Всяко приложение има .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 за една вечер, с тестовете, заедно с миграцията на всеки файл по име. Моята част беше да кажа правилата по-горе и да проверя, че скриптът наистина никога не печата стойност. Това последното го проверих два пъти.

Следващият пост: самата инфраструктура, сървър по сървър.

Коментари

Още от категорията