Files
lampac/scripts/config-secrets.py
T
prodik 25c591b45a Секреты вынесены в config/init.custom.conf + config/secrets.env
- config/init.conf — шаблон (секреты всегда пустые), в git
- config/init.custom.conf — боевой конфиг, монтируется в /lampac/init.conf,
  собирается scripts/config-secrets.py apply из шаблона + secrets.env
- docker-compose.yaml: монтирует ./config/init.custom.conf:/lampac/init.conf
- scripts/config-secrets.py: apply/export/check/status
- scripts/install-git-hooks.sh: post-merge/post-checkout -> apply,
  pre-commit -> check --staged
- scripts/restore-lampa-main.sh: восстановление веб-клиента из архива
- .gitignore: init.custom.conf, secrets.env, lampa-main/, restore.sh zip
- lampa-main смонтирован как bind-mount ./lampa-main:/lampac/wwwroot/lampa-main,
  переживает docker compose up -d (раньше исчезал из writable-слоя)
- README: раздел «Секреты», обновлена «Структура проекта» и «Обновление»,
  «Безопасность» переписана под новую схему
- AGENT: правило 10 дополнено init.custom.conf/secrets.env/хуками,
  §11 Tg-notify ссылается на apply, §1 упоминает lampa-main bind-mount
2026-10-04 15:42:16 +00:00

168 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Вынос секретов Lampac из git: шаблон config/init.conf <-> боевой config/init.custom.conf.
В git (config/init.conf) секреты всегда ПУСТЫЕ — это делает рабочее дерево чистым,
поэтому git pull/merge никогда не конфликтует и секреты не уезжают в remote.
Для сервера собирается config/init.custom.conf (шаблон + config/secrets.env),
он монтируется в контейнер вместо init.conf (см. docker-compose.yaml) и
в .gitignore.
Команды:
apply config/init.conf + config/secrets.env -> config/init.custom.conf (после git pull)
export config/init.custom.conf -> config/init.conf, секреты обнуляются (перед git add)
check проверяет, что в config/init.conf (или в staged-версии) нет секретов
status показывает, что где лежит
Обычно руками ничего не нужно: scripts/install-git-hooks.sh вешает
post-merge/post-checkout на apply и pre-commit на check.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE = os.path.join(ROOT, "config", "init.conf")
LIVE = os.path.join(ROOT, "config", "init.custom.conf")
SECRETS = os.path.join(ROOT, "config", "secrets.env")
# (секция, поле) в init.conf -> ключ в config/secrets.env
MAP = [
(("TelegramBot", "bot_token"), "TELEGRAM_NOTIFY_BOT_TOKEN"),
(("TelegramAuthBot", "bot_token"), "TELEGRAM_AUTH_BOT_TOKEN"),
(("TelegramAuth", "mutations_api_secret"), "TELEGRAM_AUTH_MUTATIONS_SECRET"),
(("TelegramAuthBot", "mutations_api_secret"), "TELEGRAM_AUTH_MUTATIONS_SECRET"),
]
def load_secrets() -> dict[str, str] | None:
"""None, если secrets.env отсутствует (тогда apply просто предупреждает)."""
if not os.path.exists(SECRETS):
return None
out: dict[str, str] = {}
for line in open(SECRETS, encoding="utf-8"):
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
def load_json(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def save_json(path: str, data: dict) -> None:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp, path)
def cmd_apply() -> None:
"""Шаблон + секреты -> боевой файл, который монтируется в контейнер."""
tpl = load_json(TEMPLATE)
sec = load_secrets()
if sec is None:
print(f"apply: нет {os.path.relpath(SECRETS, ROOT)} — "
f"{os.path.relpath(LIVE, ROOT)} не создан (секреты останутся пустыми)")
return
missing = []
for (section, field), key in MAP:
value = sec.get(key, "")
if not value:
missing.append(key)
if section in tpl:
tpl[section][field] = value
save_json(LIVE, tpl)
# контейнер пишет в боевой конфиг через AdminPanel (uid 1000)
try:
os.chown(LIVE, 1000, 1000)
except PermissionError:
pass
os.chmod(LIVE, 0o644)
print(f"apply: {os.path.relpath(LIVE, ROOT)} собран из шаблона + secrets.env")
if missing:
print(f" внимание: нет значения для {', '.join(sorted(set(missing)))} — поле останется пустым")
def cmd_export() -> None:
"""Боевой файл -> шаблон, секреты обнуляются (для git add)."""
if not os.path.exists(LIVE):
print(f"export: {os.path.relpath(LIVE, ROOT)} нет — нечего экспортировать")
return
live = load_json(LIVE)
changed = 0
for (section, field), _ in MAP:
if section in live and live.get(section, {}).get(field):
live[section][field] = ""
changed += 1
save_json(TEMPLATE, live)
print(f"export: шаблон обновлён из боевого конфига, секретов обнулено: {changed}")
def read_template_for_check(staged: bool) -> str:
if staged:
try:
return subprocess.check_output(
["git", "show", ":config/init.conf"], cwd=ROOT, text=True
)
except subprocess.CalledProcessError:
sys.exit("check: config/init.conf не в индексе")
return open(TEMPLATE, encoding="utf-8").read()
def cmd_check(staged: bool = False) -> None:
"""Падает (exit 1), если в шаблоне есть непустой секрет."""
data = json.loads(read_template_for_check(staged))
found = []
for (section, field), key in MAP:
value = (data.get(section) or {}).get(field)
if value:
found.append(f"{section}.{field} ({key}) = {value[:6]}…")
if found:
sys.exit(
"check: ОТКАЗ — в config/init.conf есть секреты, коммитить нельзя:\n "
+ "\n ".join(found)
+ "\nВыполните: python3 scripts/config-secrets.py export"
)
print("check: секретов в config/init.conf нет — ок")
def cmd_status() -> None:
tpl = load_json(TEMPLATE)
live = load_json(LIVE) if os.path.exists(LIVE) else None
print(f"{'путь':28} {'есть?' :6} секреты")
for (section, field), key in MAP:
t = (tpl.get(section) or {}).get(field)
l = (live.get(section) or {}).get(field) if live else None
print(
f"{section}.{field:24} шаблон={'заполнен' if t else 'пусто':8} "
f"боевой={'заполнен' if l else ('нет файла' if live is None else 'пусто')}"
)
print(f"secrets.env: {'есть' if os.path.exists(SECRETS) else 'НЕТ'}")
def main() -> None:
args = sys.argv[1:]
cmd = args[0] if args else "status"
if cmd == "apply":
cmd_apply()
elif cmd == "export":
cmd_export()
elif cmd == "check":
cmd_check(staged="--staged" in args)
elif cmd == "status":
cmd_status()
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()