#!/usr/bin/env python3 """테일스케일 켜기·끄기·상태 조회. 관리자님이 "테일스케일 꺼줘 / 켜줘 / 상태" 라고 하면 호출한다. sudo 불필요 — CLI가 localhost localapi + sameuserproof 토큰으로 인증한다. 사용법: python3 scripts/tailscale_ctl.py {on|off|status} 출력은 그대로 관리자님께 전달해도 되는 한국어다. 끄면 밖에서 맥으로 들어오는 길(stock.hyowons.net 등)이 전부 막히지만, 텔레그램은 아웃바운드라 영향 없다 — 다시 켜라고 시킬 길은 항상 남는다. """ import json import subprocess import sys import time TS = "/usr/local/bin/tailscale" WAIT_SEC = 20 # up 이후 연결 복구를 기다리는 상한 POLL_SEC = 1 def _run(args, timeout=30): return subprocess.run([TS, *args], capture_output=True, text=True, timeout=timeout) def _status(): """(켜짐여부, 내IPv4, [(기기이름, 온라인)]) 반환.""" r = _run(["status", "--json"], timeout=15) if r.returncode != 0: raise RuntimeError(f"상태 조회 실패: {r.stderr.strip() or f'rc={r.returncode}'}") d = json.loads(r.stdout) running = d.get("BackendState") == "Running" ip = next((x for x in (d.get("TailscaleIPs") or []) if ":" not in x), None) peers = [((p.get("DNSName") or "?").split(".")[0], bool(p.get("Online"))) for p in (d.get("Peer") or {}).values()] peers.sort(key=lambda x: (not x[1], x[0])) return running, ip, peers def _status_lines(running, ip, peers): lines = [f"테일스케일: {'켜짐' if running else '꺼짐'}"] if not running: # 꺼진 상태에선 다른 기기 상태를 알 수 없다 (캐시된 옛 값이라 보여주면 거짓말). return lines if ip: lines.append(f"내 주소: {ip}") if peers: lines.append("기기: " + " / ".join( f"{n}({'온라인' if on else '오프라인'})" for n, on in peers)) return lines def cmd_status(): print("\n".join(_status_lines(*_status()))) return 0 def cmd_on(): running, ip, peers = _status() if running: print("테일스케일은 이미 켜져 있습니다.") print("\n".join(_status_lines(running, ip, peers)[1:])) return 0 r = _run(["up"]) if r.returncode != 0: print(f"켜기 실패: {r.stderr.strip() or f'rc={r.returncode}'}", file=sys.stderr) return 1 for waited in range(WAIT_SEC): time.sleep(POLL_SEC) running, ip, peers = _status() if running and ip: print(f"테일스케일을 켰습니다. ({waited + 1}초 만에 연결)") print("\n".join(_status_lines(running, ip, peers)[1:])) return 0 print(f"up 명령은 받았지만 {WAIT_SEC}초 안에 연결되지 않았습니다. " "테일스케일 앱을 직접 확인해 주세요.", file=sys.stderr) return 1 def cmd_off(): running, _, _ = _status() if not running: print("테일스케일은 이미 꺼져 있습니다.") return 0 r = _run(["down"]) if r.returncode != 0: print(f"끄기 실패: {r.stderr.strip() or f'rc={r.returncode}'}", file=sys.stderr) return 1 time.sleep(2) running, _, _ = _status() if running: print("down 명령은 받았는데 아직 켜져 있습니다. 다시 시도해 주세요.", file=sys.stderr) return 1 print("테일스케일을 껐습니다.") print("이제 밖에서 맥으로 들어오는 길이 막힙니다 (stock.hyowons.net 등).") print('다시 켜려면 텔레그램으로 "테일스케일 켜줘" 하시면 됩니다.') return 0 CMDS = {"on": cmd_on, "off": cmd_off, "status": cmd_status} if __name__ == "__main__": if len(sys.argv) != 2 or sys.argv[1] not in CMDS: print(f"사용법: {sys.argv[0]} {{on|off|status}}", file=sys.stderr) sys.exit(2) try: sys.exit(CMDS[sys.argv[1]]()) except Exception as e: print(f"오류: {e}", file=sys.stderr) sys.exit(1)