#!/usr/bin/env python3 """작은도시 인프라 현황 v5 — 도메인 계층 + 에이전트/서브도메인 2탭""" import json, subprocess, re from pathlib import Path from datetime import datetime, timezone, timedelta KST = timezone(timedelta(hours=9)) STATUS_FILE = Path(__file__).parent / "health.json" PC_SPECS = { "macmini": {"name": "맥미니 M4 Pro", "emoji": "🖥️", "spec": "Apple M4 Pro · 64GB · 통합 GPU", "dns": "pinksky.kr"}, "mainpc": {"name": "메인컴 (PS-i14700K)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090 24GB", "dns": "mainpc-wsl.pinksky.kr"}, "subpc": {"name": "보조컴 (MH-3900x)", "emoji": "🖥️", "spec": "AMD Ryzen 9 3900X · 32GB · RTX 3080 12GB", "dns": "subpc-wsl.pinksky.kr"}, "officepc": {"name": "서버컴 (gw-ps-5600)", "emoji": "🖥️", "spec": "Intel Core i7-14700K · 96GB · RTX 3090", "dns": "gwenc.kr"}, "nas_home": {"name": "집 NAS (mh-nas)", "emoji": "🗄️", "spec": "Synology DS418+", "dns": "minicity.kr"}, "nas_office": {"name": "회사 NAS (gwenc-nas2)", "emoji": "🗄️", "spec": "Synology", "dns": ""}, "nas_backup": {"name": "백업 NAS (ps-bk-nas)", "emoji": "🗄️", "spec": "14TB HDD", "dns": ""}, "router_home": {"name": "집 공유기", "emoji": "📡", "spec": "아이피타임 AX8004BCM", "dns": "pinksky.iptime.org"}, "router_apt": {"name": "아지트 공유기", "emoji": "📡", "spec": "TP-LINK Archer BE550", "dns": "pinksky.kr"}, "router_office": {"name": "회사 공유기", "emoji": "📡", "spec": "TP-LINK AX18000", "dns": "gwenc.kr"}, } # ===== 기본 체크 함수 ===== def http_check(url, timeout=5): try: r = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}:%{time_total}", url, "--max-time", str(timeout)], capture_output=True, text=True, timeout=timeout+2) parts = r.stdout.strip().split(":") code = parts[0] if parts else "000" latency = round(float(parts[1])*1000, 0) if len(parts) > 1 and parts[1] else None return {"status": "online" if code.startswith(("1","2","3","4")) else "error", "detail": f"HTTP {code}", "latency_ms": latency} except: return {"status": "offline", "detail": "timeout", "latency_ms": None} def ssh_check(host, timeout=8): try: r = subprocess.run( ["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", host, "echo alive"], capture_output=True, text=True, timeout=timeout) alive = r.returncode == 0 and "alive" in r.stdout return {"status": "online" if alive else "offline", "detail": "alive" if alive else r.stderr.strip()[:80]} except: return {"status": "offline", "detail": "timeout"} def tailscale_ping(host, timeout=8): try: r = subprocess.run( ["tailscale", "ping", "--c", "3", host], capture_output=True, text=True, timeout=timeout) out = r.stdout + r.stderr alive = "pong" in out or "is local" in out return {"status": "online" if alive else "offline", "detail": "" if alive else "no pong"} except: return {"status": "offline", "detail": "timeout"} def tcp_port_check(host, port, timeout=5): try: r = subprocess.run( ["nc", "-z", "-w", str(timeout), host, str(port)], capture_output=True, text=True, timeout=timeout+2) alive = r.returncode == 0 return {"status": "online" if alive else "offline", "detail": "tcp open" if alive else "no response"} except: return {"status": "offline", "detail": "timeout"} def get_local_ts_ip(): try: r = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=5) return r.stdout.strip() except: return "?" def get_remote_ts(via): try: r = subprocess.run( ["ssh", "-o", "ConnectTimeout=5", via, "tailscale ip -4"], capture_output=True, text=True, timeout=8) return r.stdout.strip() if r.returncode == 0 else "?" except: return "?" def get_version(label): try: if label == "openclaw": r = subprocess.run(["openclaw", "--version"], capture_output=True, text=True, timeout=5) v = r.stdout.strip().replace("OpenClaw ", "") v = re.sub(r'\s+\([^)]+\)', '', v).strip() return v if v else "?" elif label == "hermes": r = subprocess.run([ "/Users/pinksky/.hermes/hermes-agent/venv/bin/python", "-m", "hermes_cli.main", "--version"], capture_output=True, text=True, timeout=5) m = re.search(r'v[\d.]+', r.stdout.strip()) return m.group(0) if m else "?" except: return "?" def get_remote_hermes_version(via): try: r = subprocess.run( ["ssh", "-o", "ConnectTimeout=5", via, "export PATH=\"$HOME/.local/bin:$PATH\"; hermes --version"], capture_output=True, text=True, timeout=8) m = re.search(r'v[\d.]+', r.stdout.strip()) return m.group(0) if m else "?" except: return "?" def proc_detail(pattern, engine_name="?"): result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0, "engine": engine_name, "detail": "not running"} try: r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=5) pids = [p.strip() for p in r.stdout.strip().split("\n") if p.strip()] if pids: result["status"] = "online" result["proc_count"] = len(pids) try: u = subprocess.run(["ps", "-p", pids[0], "-o", "etime="], capture_output=True, text=True, timeout=5) result["uptime"] = u.stdout.strip() except: pass result["detail"] = f"{result['uptime']} · {result['proc_count']}프로세스" except: pass return result def remote_proc(via, pattern, engine_name="?"): result = {"status": "offline", "version": "?", "uptime": "?", "proc_count": 0, "engine": engine_name, "detail": "not running"} try: r = subprocess.run( ["ssh", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=no", via, f"ps aux | grep '{pattern}' | grep -v grep || true"], capture_output=True, text=True, timeout=8) lines = [l for l in r.stdout.strip().split("\n") if l.strip()] if lines: result["status"] = "online" result["proc_count"] = len(lines) result["detail"] = f"{result['proc_count']}프로세스" except: pass return result # --- NAS storage info --- NAS_CONFIGS = { "nas_home": { "name": "집 NAS (mh-nas)", "model": "Synology DS418+", "os": "DSM 7.2", "ssh_host": "mh-nas", "volumes": [{"mount": "/volume1", "label": "데이터"}, {"mount": "/volume2", "label": "백업"}] }, "nas_office": { "name": "회사 NAS (gwenc-nas2)", "model": "Synology DS923+", "os": "DSM 7.2", "ssh_host": "100.105.95.19", "ssh_port": 55, "ssh_user": "psbot", "ssh_pass": "@@Mini3388", "volumes": [{"mount": "/volume1", "label": "메인"}, {"mount": "/volume3", "label": "SSD캐시"}, {"mount": "/volume2", "label": "대용량스토리지"}] }, "nas_backup": { "name": "백업 NAS (ps-bk-nas)", "model": "Synology", "os": "DSM 7.x", "ssh_host": "100.83.176.55", "ssh_port": 55, "ssh_user": "psbot", "ssh_pass": "@@Mini3388", "volumes": [{"mount": "/volume1", "label": "메인백업"}, {"mount": "/volume2", "label": "대용량백업"}] } } def get_nas_storage(nas_key): cfg = NAS_CONFIGS[nas_key] result = {"name": cfg["name"], "model": cfg["model"], "os": cfg["os"], "status": "offline", "volumes": [], "detail": "not checked"} host = cfg.get("ssh_host") if not host: return result port = cfg.get("ssh_port", 55) user = cfg.get("ssh_user", "psbot") passwd = cfg.get("ssh_pass", "") try: mounts = " ".join(v["mount"] for v in cfg["volumes"]) ssh_cmd = f"ssh -p {port} -o ConnectTimeout=5 -o StrictHostKeyChecking=no {user}@{host} 'df -h {mounts}'" if passwd: full_cmd = f"sshpass -p {passwd!r} {ssh_cmd}" else: full_cmd = ssh_cmd r = subprocess.run(full_cmd, shell=True, capture_output=True, text=True, timeout=10) if r.returncode != 0: result["detail"] = r.stderr.strip()[:80] return result result["status"] = "online" result["detail"] = "alive" volumes = [] for line in r.stdout.strip().split("\n")[1:]: parts = line.split() if len(parts) >= 6: mount = parts[5] label = mount for v in cfg["volumes"]: if v["mount"] == mount: label = v["label"] break volumes.append({ "mount": mount, "label": label, "total": parts[1], "used": parts[2], "free": parts[3], "pct": parts[4] }) result["volumes"] = volumes except Exception as e: result["detail"] = str(e)[:80] return result # --- Proxmox VM info (서버컴 Hyper-V 위) --- PROXMOX_CFG = { "host": { "name": "서버컴 (gw-ps-5600)", "model": "ASRock Z790M-ITX WiFi", "cpu": "Intel Core i7-14700K", "ram": "96GB", "os": "Windows 11 Pro + Hyper-V", "role": "Hyper-V 호스트" }, "proxmox": { "name": "Proxmox VE", "version": "8.x", "os": "Debian 12 + Proxmox", "cpu": "8 vCPU", "ram": "32GB", "storage": "1TB SSD", "role": "Hyper-V 가상머신 · VM 호스팅" }, "vms": [ {"name": "정환용 업무컴", "host": "gwenc.kr", "port": 13389, "emoji": "🖥️"}, {"name": "주영용 업무컴", "host": "gwenc.kr", "port": 23389, "emoji": "🖥️"}, {"name": "임시용 업무컴", "host": "gwenc.kr", "port": 43348, "emoji": "🖥️"}, ] } # ===== 메인 ===== def run_checks(): ts = datetime.now(KST) ts_ip = get_local_ts_ip() # --- PC checks --- mainpc_ssh = ssh_check("mainpc-win", 6) mainpc_ts = get_remote_ts("mainpc-wsl") subpc_ssh = ssh_check("subpc-wsl", 6) subpc_ts = get_remote_ts("subpc-wsl") office_pc = tcp_port_check("gwenc.kr", 33389, 5) # --- NAS checks --- nas_mh = ssh_check("mh-nas", 6) nas_gwenc2 = tailscale_ping("gwenc-nas2", 6) nas_bk = tailscale_ping("ps-bk-nas", 6) # --- NAS storage info --- nas_storage = {} for nk in NAS_CONFIGS: nas_storage[nk] = get_nas_storage(nk) # --- Proxmox host check (TCP RDP 포트) --- proxmox_host = tcp_port_check("gwenc.kr", 33389, 5) # --- Proxmox VE Web UI check --- proxmox_ve = tcp_port_check("gwenc.kr", 18006, 5) # --- VM RDP checks --- vm_results = [] for vm in PROXMOX_CFG.get("vms", []): vm_results.append({**vm, **tcp_port_check(vm["host"], vm["port"], 5)}) # --- Agent checks --- mimi = proc_detail("openclaw", "openclaw") mimi["name"] = "미미" mimi["emoji"] = "🦞" mimi["version"] = get_version("openclaw") ruki = proc_detail("hermes", "hermes") ruki["name"] = "루키" ruki["emoji"] = "🌱" ruki["version"] = get_version("hermes") pink = remote_proc("mainpc-wsl", "hermes", "hermes") pink["name"] = "분홍" pink["emoji"] = "🤖" pink["version"] = get_remote_hermes_version("mainpc-wsl") sky = remote_proc("subpc-wsl", "hermes", "hermes") sky["name"] = "하늘" sky["emoji"] = "🤖" sky["version"] = get_remote_hermes_version("subpc-wsl") # --- Subdomains --- subdomains = [ {"name": "wiki.pinksky.kr", "url": "https://wiki.pinksky.kr/", "group": "pinksky.kr", "desc": "MiniCITY 지식정원"}, {"name": "openclaw.pinksky.kr", "url": "https://openclaw.pinksky.kr/", "group": "pinksky.kr", "desc": "OpenClaw 대시보드"}, {"name": "agent.pinksky.kr", "url": "https://agent.pinksky.kr/", "group": "pinksky.kr", "desc": "인프라 현황판"}, {"name": "search.pinksky.kr", "url": "https://search.pinksky.kr/search?q=test", "group": "pinksky.kr", "desc": "SearXNG 메타서치"}, {"name": "erp.pinksky.kr", "url": "https://erp.pinksky.kr/", "group": "pinksky.kr", "desc": "공사관리 ERP"}, {"name": "dify.pinksky.kr", "url": "https://dify.pinksky.kr/", "group": "pinksky.kr", "desc": "Dify 워크플로우"}, {"name": "photo.pinksky.kr", "url": "https://photo.pinksky.kr/", "group": "pinksky.kr", "desc": "PhotoVault 자료정리"}, {"name": "git.pinksky.kr", "url": "https://git.pinksky.kr/", "group": "pinksky.kr", "desc": "Gitea Git 서버"}, {"name": "find.pinksky.kr", "url": "https://find.pinksky.kr/", "group": "pinksky.kr", "desc": "Everything 검색"}, {"name": "gwenc.kr", "url": "https://gwenc.kr/", "group": "gwenc.kr", "desc": "회사 메인 사이트"}, {"name": "minicity.kr", "url": "https://minicity.kr/", "group": "minicity.kr", "desc": "집 NAS 웹 UI"}, ] sub_results = [] for s in subdomains: chk = http_check(s["url"], 4) sub_results.append({ "name": s["name"], "group": s["group"], "desc": s["desc"], "status": chk["status"], "detail": chk["detail"], "latency_ms": chk.get("latency_ms") }) # --- Domain hierarchy --- domains = { "pinksky.kr": { "domain": {"status": "online", "detail": f"{ts_ip}"}, "pcs": { "macmini": { **PC_SPECS["macmini"], "tailscale_ip": ts_ip, "status": "online", "detail": "", "agents": { "mimi": {**mimi}, "ruki": {**ruki}, } }, "mainpc": { **PC_SPECS["mainpc"], "tailscale_ip": mainpc_ts, "status": mainpc_ssh["status"], "detail": mainpc_ssh["detail"], "agents": { "pink": {**pink}, } }, "subpc": { **PC_SPECS["subpc"], "tailscale_ip": subpc_ts, "status": subpc_ssh["status"], "detail": subpc_ssh["detail"], "agents": { "sky": {**sky}, } } } }, "minicity.kr": { "domain": {"status": "online", "detail": "집 NAS/공유기"}, "pcs": { "nas_home": { **PC_SPECS["nas_home"], "tailscale_ip": "100.69.107.65", "status": nas_mh["status"], "detail": nas_mh["detail"], "agents": {} }, "router_home": { **PC_SPECS["router_home"], "tailscale_ip": "", "status": "online", "detail": "DDNS · 원격관리 9090", "agents": {} } } }, "gwenc.kr": { "domain": {"status": office_pc["status"], "detail": office_pc["detail"]}, "pcs": { "officepc": { **PC_SPECS["officepc"], "tailscale_ip": "", "status": office_pc["status"], "detail": office_pc["detail"], "agents": {} }, "nas_office": { **PC_SPECS["nas_office"], "tailscale_ip": "100.105.95.19", "status": nas_gwenc2["status"], "detail": nas_gwenc2["detail"], "agents": {} }, "nas_backup": { **PC_SPECS["nas_backup"], "tailscale_ip": "100.83.176.55", "status": nas_bk["status"], "detail": nas_bk["detail"], "agents": {} }, "router_office": { **PC_SPECS["router_office"], "tailscale_ip": "", "status": "online", "detail": "원격관리 9000/9443", "agents": {} } } } } # --- Summary --- all_items = [] for d in domains.values(): for pc in d["pcs"].values(): all_items.append(pc) for a in pc.get("agents", {}).values(): all_items.append(a) for s in sub_results: all_items.append(s) for vm in vm_results: all_items.append(vm) online = sum(1 for x in all_items if x["status"] == "online") offline = sum(1 for x in all_items if x["status"] == "offline") data = { "subdomains": sub_results, "domains": domains, "nas_storage": nas_storage, "proxmox": { "host": { **PROXMOX_CFG["host"], "status": proxmox_host["status"], "detail": proxmox_host["detail"] }, "proxmox": { **PROXMOX_CFG["proxmox"], "status": proxmox_ve["status"], "detail": proxmox_ve["detail"] }, "vms": vm_results }, "summary": { "total": len(all_items), "online": online, "offline": offline, "domain_total": 3, "domain_online": sum(1 for d in domains.values() if d["domain"]["status"] == "online") }, "timestamp": ts.isoformat(), "timestamp_epoch": int(ts.timestamp()) } STATUS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) print(f"[{ts.strftime('%H:%M:%S')}] Online {online}/{len(all_items)} · Domains {data['summary']['domain_online']}/3") if __name__ == "__main__": run_checks()