auto: 일일 백업 2026-10-02 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user