Секреты вынесены в 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:
@@ -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()
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Вешает git-хуки, которые держат секреты вне git.
|
||||
# post-merge / post-checkout -> apply (собирает config/init.custom.conf)
|
||||
# pre-commit -> check (отменяет коммит с секретами)
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
mkdir -p .git/hooks
|
||||
|
||||
cat > .git/hooks/post-merge <<'H'
|
||||
#!/bin/sh
|
||||
exec python3 scripts/config-secrets.py apply
|
||||
H
|
||||
|
||||
cat > .git/hooks/post-checkout <<'H'
|
||||
#!/bin/sh
|
||||
exec python3 scripts/config-secrets.py apply
|
||||
H
|
||||
|
||||
cat > .git/hooks/pre-commit <<'H'
|
||||
#!/bin/sh
|
||||
exec python3 scripts/config-secrets.py check --staged
|
||||
H
|
||||
|
||||
chmod +x .git/hooks/post-merge .git/hooks/post-checkout .git/hooks/pre-commit
|
||||
echo "хуки установлены:"
|
||||
ls -l .git/hooks/post-merge .git/hooks/post-checkout .git/hooks/pre-commit
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Восстанавливает веб-клиент Lampa (wwwroot/lampa-main), который LampaCron
|
||||
# берёт с github.com и который пропадает при пересоздании контейнера
|
||||
# (нет в образе, живёт только в writable-слое).
|
||||
#
|
||||
# bash scripts/restore-lampa-main.sh scripts/lampa-main.zip
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
ZIP="${1:-scripts/lampa-main.zip}"
|
||||
if [ ! -f "$ZIP" ]; then
|
||||
echo "нет $ZIP — берите архив с https://github.com/yumata/lampa/archive/refs/heads/main.zip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec lampac sh -c "rm -rf /lampac/wwwroot/lampa-main /lampac/wwwroot/lampa.zip"
|
||||
docker cp "$ZIP" lampac:/lampac/wwwroot/lampa.zip
|
||||
|
||||
docker exec lampac sh -c '
|
||||
cd /lampac/wwwroot
|
||||
unzip -q -o lampa-main.zip
|
||||
mv lampa-main-main lampa-main 2>/dev/null || mv lampa-main lampa-main 2>/dev/null || true
|
||||
chown -R 1000:1000 lampa-main
|
||||
rm -f lampa.zip
|
||||
# инъекция lampainit.js (делает LampaCron при следующем старте)
|
||||
python3 - <<PY
|
||||
import os
|
||||
p="/lampac/wwwroot/lampa-main/index.html"
|
||||
html=open(p,encoding="utf-8").read()
|
||||
if "src=\"/lampainit.js\"" not in html:
|
||||
html=html.replace("</body>", "<script src=\"/lampainit.js\"></script></body>")
|
||||
open(p,"w",encoding="utf-8").write(html)
|
||||
print("index.html: lampainit.js внедрён")
|
||||
else:
|
||||
print("index.html: уже был")
|
||||
PY
|
||||
'
|
||||
|
||||
echo "lampa-main восстановлен ($(docker exec lampac sh -c 'find /lampac/wwwroot/lampa-main -type f | wc -l') файлов)"
|
||||
Reference in New Issue
Block a user