auto: 일일 백업 2026-10-02 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-10-02 02:00:07 +09:00
parent 74dcab1ba6
commit b404af9020
138 changed files with 2533 additions and 2193 deletions
+119
View File
@@ -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)