b404af9020
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
4.0 KiB
Python
Executable File
120 lines
4.0 KiB
Python
Executable File
#!/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)
|