feat(discord-bridge): punte Discord -> Claude Code pe LXC 171
Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri, 3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu proces persistent per fir alimentat pe stdin cu --input-format stream-json. Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream (parser tolerant), session_store (scriere atomica, lock per fir, detectare PID reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost pe zi), render (un loop de editare per canal, interval adaptiv). Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor, comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou. Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa). Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd user cu KillMode=control-group si limite de memorie, install.sh idempotent. Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s intr-un tool call de 25s schimba raspunsul final). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
192
proxmox/lxc171-claude-agent/discord-bridge/security/infra
Executable file
192
proxmox/lxc171-claude-agent/discord-bridge/security/infra
Executable file
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""infra -- singura poarta prin care puntea Discord atinge infrastructura.
|
||||
|
||||
infra <host> <comanda...> ruleaza comanda pe hostul din lista
|
||||
infra --list arata hosturile permise
|
||||
infra --help
|
||||
|
||||
Hosturile sunt o lista EXPLICITA. Un host care nu e in lista este refuzat, fara
|
||||
incercare de rezolvare DNS. Fiecare apel este jurnalizat in
|
||||
~/.claude-discord/logs/infra.log cu data, host, comanda completa si rezultat.
|
||||
|
||||
Lista implicita poate fi inlocuita cu ~/.claude-discord/infra-hosts.json:
|
||||
|
||||
{"pvemini": {"addr": "10.0.20.201", "user": "root", "prod": true,
|
||||
"desc": "nod Proxmox principal"}}
|
||||
|
||||
Coduri de iesire proprii wrapper-ului (comenzile remote isi pastreaza codul lor):
|
||||
2 utilizare gresita (lipseste hostul sau comanda)
|
||||
3 host in afara listei
|
||||
4 eroare de configurare (fisier de hosturi corupt)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
EXIT_USAGE = 2
|
||||
EXIT_UNKNOWN_HOST = 3
|
||||
EXIT_CONFIG = 4
|
||||
|
||||
# Lista implicita, din tabelul de retea al repo-ului (CLAUDE.md, proxmox/README.md).
|
||||
DEFAULT_HOSTS: dict[str, dict] = {
|
||||
# noduri Proxmox
|
||||
"pve1": {"addr": "10.0.20.200", "user": "root", "prod": True, "desc": "nod Proxmox pve1"},
|
||||
"pvemini": {"addr": "10.0.20.201", "user": "root", "prod": True, "desc": "nod Proxmox principal"},
|
||||
"pveelite": {"addr": "10.0.20.202", "user": "root", "prod": True, "desc": "nod Proxmox pveelite"},
|
||||
# servicii interne
|
||||
"oracle": {"addr": "10.0.20.121", "user": "root", "prod": False, "desc": "LXC 108 Oracle XE 21c/18c"},
|
||||
"flowise": {"addr": "10.0.20.161", "user": "root", "prod": False, "desc": "LXC 104 Flowise"},
|
||||
"gitea": {"addr": "10.0.20.165", "user": "root", "prod": False, "desc": "LXC Gitea"},
|
||||
"docker": {"addr": "10.0.20.113", "user": "root", "prod": False, "desc": "LXC 102 Docker + Portainer"},
|
||||
"moltbot": {"addr": "10.0.20.173", "user": "root", "prod": False, "desc": "LXC 110 MoltBot"},
|
||||
"dokploy": {"addr": "10.0.20.167", "user": "root", "prod": False, "desc": "LXC 103 Dokploy"},
|
||||
# productie / clienti
|
||||
"oracle-prod": {"addr": "10.0.20.36", "user": "romfast", "prod": True, "desc": "server Oracle de PRODUCTIE"},
|
||||
"oracle-dr": {"addr": "10.0.20.37", "user": "romfast", "prod": True, "desc": "server Oracle DR"},
|
||||
"roacentral": {"addr": "10.0.20.122", "user": "romfast", "prod": True, "desc": "VM 201 Windows, IIS reverse proxy"},
|
||||
"oracle-test": {"addr": "10.0.20.130", "user": "romfast", "prod": False, "desc": "VM 302 mediu de test"},
|
||||
}
|
||||
|
||||
SSH_OPTS = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=10",
|
||||
]
|
||||
|
||||
|
||||
def state_dir() -> pathlib.Path:
|
||||
override = os.environ.get("CLAUDE_DISCORD_DIR")
|
||||
if override:
|
||||
return pathlib.Path(override)
|
||||
return pathlib.Path.home() / ".claude-discord"
|
||||
|
||||
|
||||
def hosts_file() -> pathlib.Path:
|
||||
return state_dir() / "infra-hosts.json"
|
||||
|
||||
|
||||
def log_file() -> pathlib.Path:
|
||||
return state_dir() / "logs" / "infra.log"
|
||||
|
||||
|
||||
def load_hosts() -> dict[str, dict]:
|
||||
"""Lista de hosturi: fisierul de pe disc daca exista, altfel cea implicita."""
|
||||
path = hosts_file()
|
||||
if not path.is_file():
|
||||
return dict(DEFAULT_HOSTS)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise SystemExit(_fail(EXIT_CONFIG, f"infra: {path} nu e JSON valid ({exc})"))
|
||||
if not isinstance(data, dict) or not data:
|
||||
raise SystemExit(_fail(EXIT_CONFIG, f"infra: {path} nu contine hosturi"))
|
||||
out: dict[str, dict] = {}
|
||||
for name, spec in data.items():
|
||||
if isinstance(spec, str):
|
||||
spec = {"addr": spec}
|
||||
if not isinstance(spec, dict) or not spec.get("addr"):
|
||||
raise SystemExit(_fail(EXIT_CONFIG, f"infra: intrare invalida pentru '{name}'"))
|
||||
out[str(name)] = {
|
||||
"addr": str(spec["addr"]),
|
||||
"user": str(spec.get("user") or "root"),
|
||||
"prod": bool(spec.get("prod", False)),
|
||||
"desc": str(spec.get("desc") or ""),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _fail(code: int, msg: str) -> int:
|
||||
sys.stderr.write(msg + "\n")
|
||||
return code
|
||||
|
||||
|
||||
def log(host: str, target: str, cmd: list[str], rc, dur_s: float, note: str = "") -> None:
|
||||
"""Jurnal pe o linie: data, host, comanda completa, rezultat."""
|
||||
line = (
|
||||
f"{datetime.datetime.now().isoformat(timespec='seconds')}\t"
|
||||
f"host={host}\ttarget={target}\trc={rc}\tdur={dur_s:.2f}s\t"
|
||||
f"cmd={shlex.join(cmd) if cmd else ''}"
|
||||
)
|
||||
if note:
|
||||
line += f"\tnote={note}"
|
||||
try:
|
||||
path = log_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(line + "\n")
|
||||
except OSError:
|
||||
pass # un jurnal care nu se poate scrie nu opreste comanda
|
||||
|
||||
|
||||
def usage(hosts: dict[str, dict]) -> str:
|
||||
lines = ["infra <host> <comanda...>", "", "Hosturi permise:"]
|
||||
for name, spec in sorted(hosts.items()):
|
||||
flag = " [PRODUCTIE]" if spec.get("prod") else ""
|
||||
lines.append(f" {name:<13} {spec['user']}@{spec['addr']:<13} {spec.get('desc','')}{flag}")
|
||||
lines.append("")
|
||||
lines.append(f"Lista se poate inlocui prin {hosts_file()}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
hosts = load_hosts()
|
||||
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(usage(hosts))
|
||||
return 0 if argv else EXIT_USAGE
|
||||
if argv[0] in ("-l", "--list"):
|
||||
print(usage(hosts))
|
||||
return 0
|
||||
|
||||
host = argv[0]
|
||||
cmd = argv[1:]
|
||||
|
||||
if host not in hosts:
|
||||
log(host, "-", cmd, "refuzat", 0.0, note="host in afara listei")
|
||||
return _fail(
|
||||
EXIT_UNKNOWN_HOST,
|
||||
f"infra: host necunoscut '{host}'. Hosturi permise: "
|
||||
+ ", ".join(sorted(hosts)),
|
||||
)
|
||||
if not cmd:
|
||||
return _fail(EXIT_USAGE, f"infra: lipseste comanda pentru '{host}'")
|
||||
|
||||
spec = hosts[host]
|
||||
target = f"{spec['user']}@{spec['addr']}"
|
||||
ssh_argv = ["ssh", *SSH_OPTS, target, "--", *cmd]
|
||||
|
||||
if os.environ.get("INFRA_DRY_RUN"):
|
||||
log(host, target, cmd, "dry-run", 0.0, note="INFRA_DRY_RUN")
|
||||
print(shlex.join(ssh_argv))
|
||||
return 0
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
proc = subprocess.run(ssh_argv)
|
||||
rc = proc.returncode
|
||||
except FileNotFoundError:
|
||||
log(host, target, cmd, "eroare", time.monotonic() - t0, note="ssh lipseste")
|
||||
return _fail(EXIT_CONFIG, "infra: `ssh` nu exista in PATH")
|
||||
except KeyboardInterrupt:
|
||||
log(host, target, cmd, "intrerupt", time.monotonic() - t0)
|
||||
return 130
|
||||
log(host, target, cmd, rc, time.monotonic() - t0)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # nimic nu iese neraportat
|
||||
sys.stderr.write(f"infra: eroare interna: {exc}\n")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user