Capture current Ansible control plane state

Commit the accumulated infrastructure work that was living only in the
working tree: monitoring stack, emergency access/bot, gyro allocator,
grimmory, adguard, backup audit and the OpenCode agent definitions.

Also ignore Python bytecode, local archives and Nix/direnv artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
This commit is contained in:
Dmitry
2026-08-26 21:39:28 +03:00
co-authored by Claude Opus 5
parent 4bafa7d09e
commit c676be81ec
126 changed files with 10583 additions and 44 deletions
@@ -0,0 +1,8 @@
---
emergency_bot_user: emergency-bot
emergency_bot_state_dir: /var/lib/emergency-bot
emergency_bot_config_dir: /etc/emergency-bot
emergency_bot_control_key_path: /var/lib/emergency-bot/control_ed25519
emergency_bot_mini_pc_host: 192.168.1.10
emergency_bot_mini_pc_user: emergency-control
emergency_telegram_proxy: http://192.168.1.27:7890
@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Restart emergency bot
ansible.builtin.systemd_service:
name: emergency-bot.service
state: restarted
@@ -0,0 +1,48 @@
---
- name: Create emergency bot user
ansible.builtin.user:
name: "{{ emergency_bot_user }}"
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Create emergency bot state directory
ansible.builtin.file:
path: "{{ emergency_bot_state_dir }}"
state: directory
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "0700"
- name: Generate emergency bot control key
ansible.builtin.command:
cmd: "ssh-keygen -q -t ed25519 -N '' -f {{ emergency_bot_control_key_path }}"
creates: "{{ emergency_bot_control_key_path }}"
become: true
become_user: "{{ emergency_bot_user }}"
no_log: true
- name: Set emergency bot control key ownership
ansible.builtin.file:
path: "{{ item.path }}"
state: file
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ emergency_bot_control_key_path }}"
mode: "0600"
- path: "{{ emergency_bot_control_key_path }}.pub"
mode: "0644"
no_log: true
- name: Read emergency bot control public key
ansible.builtin.slurp:
src: "{{ emergency_bot_control_key_path }}.pub"
register: emergency_bot_control_key
no_log: true
- name: Store emergency bot control public key for mini-pc configuration
ansible.builtin.set_fact:
emergency_bot_control_public_key: "{{ emergency_bot_control_key.content | b64decode | trim }}"
no_log: true
@@ -0,0 +1,77 @@
---
- name: Bootstrap emergency bot control identity
ansible.builtin.import_tasks: bootstrap.yml
- name: Validate emergency bot inputs
ansible.builtin.assert:
that:
- emergency_bot_token is match('^[0-9]+:[A-Za-z0-9_-]+$')
- emergency_bot_allowed_user_ids is match('^[0-9]+(,[0-9]+)*$')
- emergency_mini_pc_host_key is match('^ssh-(ed25519|rsa|ecdsa-[^ ]+) [A-Za-z0-9+/=]+( [A-Za-z0-9@._:-]+)?$')
fail_msg: Set EMERGENCY_BOT_TOKEN, EMERGENCY_ALLOWED_USER_IDS and EMERGENCY_MINI_PC_HOST_KEY.
no_log: true
- name: Install emergency bot dependencies
ansible.builtin.apt:
name:
- ca-certificates
- openssh-client
- python3
state: present
update_cache: true
- name: Create emergency bot configuration directory
ansible.builtin.file:
path: "{{ emergency_bot_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Pin mini-pc SSH host key
ansible.builtin.copy:
dest: "{{ emergency_bot_state_dir }}/known_hosts"
content: "mini-pc-emergency {{ emergency_mini_pc_host_key }}\n"
owner: "{{ emergency_bot_user }}"
group: "{{ emergency_bot_user }}"
mode: "0600"
no_log: true
- name: Install emergency bot runtime
ansible.builtin.template:
src: emergency_bot.py.j2
dest: /usr/local/libexec/emergency-bot
owner: root
group: root
mode: "0755"
notify: Restart emergency bot
- name: Install emergency bot environment
ansible.builtin.template:
src: emergency-bot.env.j2
dest: "{{ emergency_bot_config_dir }}/bot.env"
owner: root
group: root
mode: "0600"
no_log: true
notify: Restart emergency bot
- name: Install emergency bot systemd unit
ansible.builtin.template:
src: emergency-bot.service.j2
dest: /etc/systemd/system/emergency-bot.service
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Restart emergency bot
- name: Enable and start emergency bot
ansible.builtin.meta: flush_handlers
- name: Enable and start emergency bot
ansible.builtin.systemd_service:
name: emergency-bot.service
enabled: true
state: started
@@ -0,0 +1,7 @@
EMERGENCY_BOT_TOKEN={{ emergency_bot_token }}
EMERGENCY_ALLOWED_USER_IDS={{ emergency_bot_allowed_user_ids }}
EMERGENCY_MINI_PC_HOST={{ emergency_bot_mini_pc_host }}
EMERGENCY_MINI_PC_USER={{ emergency_bot_mini_pc_user }}
EMERGENCY_CONTROL_KEY={{ emergency_bot_control_key_path }}
EMERGENCY_KNOWN_HOSTS={{ emergency_bot_state_dir }}/known_hosts
EMERGENCY_TELEGRAM_PROXY={{ emergency_telegram_proxy }}
@@ -0,0 +1,20 @@
[Unit]
Description=HomeLab emergency access Telegram bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ emergency_bot_user }}
EnvironmentFile={{ emergency_bot_config_dir }}/bot.env
ExecStart=/usr/local/libexec/emergency-bot
Restart=always
RestartSec=10
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ emergency_bot_state_dir }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
import urllib.parse
import urllib.request
API = "https://api.telegram.org/bot" + os.environ["EMERGENCY_BOT_TOKEN"]
ALLOWED_USERS = {item.strip() for item in os.environ["EMERGENCY_ALLOWED_USER_IDS"].split(",") if item.strip()}
STATE_FILE = "{{ emergency_bot_state_dir }}/updates.json"
BUTTONS = {
"inline_keyboard": [[
{"text": "Enable SSH", "callback_data": "start"},
{"text": "Status", "callback_data": "status"},
{"text": "Stop", "callback_data": "stop"},
]]
}
HTTP = urllib.request.build_opener(
urllib.request.ProxyHandler({"https": os.environ["EMERGENCY_TELEGRAM_PROXY"]})
)
def api(method, payload=None, timeout=35):
if payload is not None:
payload = {
key: json.dumps(value) if isinstance(value, (dict, list)) else value
for key, value in payload.items()
}
data = None if payload is None else urllib.parse.urlencode(payload).encode()
with HTTP.open(API + "/" + method, data=data, timeout=timeout) as response:
result = json.load(response)
if not result.get("ok"):
raise RuntimeError(result.get("description", "Telegram API request failed"))
return result["result"]
def control(command):
args = [
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "HostKeyAlias=mini-pc-emergency", "-o", "UserKnownHostsFile=" + os.environ["EMERGENCY_KNOWN_HOSTS"],
"-i", os.environ["EMERGENCY_CONTROL_KEY"],
os.environ["EMERGENCY_MINI_PC_USER"] + "@" + os.environ["EMERGENCY_MINI_PC_HOST"], command,
]
result = subprocess.run(args, capture_output=True, text=True, timeout=25, check=False)
output = (result.stdout or result.stderr).strip()
if result.returncode:
return "Control command failed: " + (output or "unknown error")[:1000]
return output or "ok"
def reply(chat_id, text):
payload = {"chat_id": chat_id, "text": text[:3500], "reply_markup": BUTTONS}
if text.startswith("started; expires in") and "\nConnect:\n" in text:
status, command = text.split("\nConnect:\n", 1)
payload["text"] = status + "\nConnect:\n`" + command + "`"
payload["parse_mode"] = "Markdown"
api("sendMessage", payload)
def allowed(chat, sender):
return chat.get("type") == "private" and str(sender.get("id")) in ALLOWED_USERS
def handle_command(chat_id, command):
if command == "help":
reply(chat_id, "Choose an action or use /emergency ssh, /emergency status, /emergency stop")
elif command:
print("accepted emergency command: " + command, file=sys.stderr, flush=True)
reply(chat_id, control(command))
def load_offset():
try:
with open(STATE_FILE, encoding="utf-8") as state_file:
return int(json.load(state_file).get("offset", 0))
except (FileNotFoundError, ValueError, json.JSONDecodeError):
return 0
def save_offset(offset):
temporary = STATE_FILE + ".tmp"
with open(temporary, "w", encoding="utf-8") as state_file:
json.dump({"offset": offset}, state_file)
os.replace(temporary, STATE_FILE)
def command_for(text):
parts = text.strip().split()
if not parts:
return None
command_name = parts[0].split("@", 1)[0]
if command_name == "/start":
return "help"
if command_name != "/emergency":
return None
if len(parts) != 2:
return "help"
return {"ssh": "start", "stop": "stop", "status": "status"}.get(parts[1], "help")
def run():
offset = load_offset()
while True:
try:
for update in api("getUpdates", {"offset": offset, "timeout": 30}, timeout=40):
offset = update["update_id"] + 1
save_offset(offset)
callback = update.get("callback_query")
if callback:
callback_message = callback.get("message", {})
chat = callback_message.get("chat", {})
sender = callback.get("from", {})
command = callback.get("data")
if not allowed(chat, sender) or command not in {"start", "status", "stop"}:
print("ignored callback: chat_type=" + str(chat.get("type")) + " sender=" + str(sender.get("id")), file=sys.stderr, flush=True)
api("answerCallbackQuery", {"callback_query_id": callback["id"], "text": "Not authorized", "show_alert": True})
else:
api("answerCallbackQuery", {"callback_query_id": callback["id"]})
handle_command(chat["id"], command)
continue
message = update.get("message", {})
chat = message.get("chat", {})
sender = message.get("from", {})
text = message.get("text", "")
if not allowed(chat, sender):
print("ignored update: chat_type=" + str(chat.get("type")) + " sender=" + str(sender.get("id")), file=sys.stderr, flush=True)
continue
command = command_for(text)
handle_command(chat["id"], command)
except Exception as error:
print("emergency-bot error: " + str(error), file=sys.stderr, flush=True)
time.sleep(10)
if __name__ == "__main__":
run()