Секреты вынесены в 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
This commit is contained in:
2026-10-04 15:42:16 +00:00
parent 24d2dac051
commit 25c591b45a
7 changed files with 297 additions and 11 deletions
+167
View File
@@ -0,0 +1,167 @@
#!/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()