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:
@@ -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()
|
||||
Reference in New Issue
Block a user