#!/usr/bin/env python3 """화면이 숨기고 있는 기기 줄을 세어 봅니다 (운영 매뉴얼 M3). 🟢 읽기만 합니다. 아무것도 바꾸지 않습니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Zero Trust Read 면 충분합니다 export CF_ACCOUNT_ID=<계정 ID> # Zero Trust 화면 주소의 32자리 python hidden.py 왜 세어 보나 — Devices 화면의 필터 기본값이 「등록이 살아 있는 기기만」이라 등록 0개짜리 줄은 목록에 아예 안 나옵니다. API 도 똑같습니다: physical-devices active_registrations 기본 "only" (include / only / exclude) registrations status 기본 "active" (active / all / revoked) 그래서 「지웠는데 왜 안 줄지?」의 절반은 **안 보이는 줄**입니다. 공식 문서 기기 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/devices/methods/list/ 등록 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/registrations/methods/list/ """ import json, os, sys, urllib.parse, urllib.request API = "https://api.cloudflare.com/client/v4" TOKEN = os.environ.get("CF_API_TOKEN", "") ACCOUNT = os.environ.get("CF_ACCOUNT_ID", "") if not TOKEN or not ACCOUNT: sys.exit("CF_API_TOKEN 과 CF_ACCOUNT_ID 를 환경변수로 넣어 주세요.") def get_all(path, params=None): """커서 페이징. 🔴 page= 는 2750 not supported 로 죽습니다 — 커서만 씁니다.""" out, cursor = [], "" for _ in range(50): q = dict(params or {}) q["per_page"] = "100" if cursor: q["cursor"] = cursor url = API + "/accounts/" + ACCOUNT + path + "?" + urllib.parse.urlencode(q) req = urllib.request.Request(url) req.add_header("Authorization", "Bearer " + TOKEN) with urllib.request.urlopen(req, timeout=30) as r: j = json.loads(r.read().decode("utf-8")) if not j.get("success", True): sys.exit("조회 실패: " + json.dumps(j.get("errors"), ensure_ascii=False)) batch = j.get("result") or [] out.extend(batch) nxt = (j.get("result_info") or {}).get("cursor") or "" if not nxt or nxt == cursor or not batch: break cursor = nxt return out shown = get_all("/devices/physical-devices") # 화면 기본값 every = get_all("/devices/physical-devices", {"active_registrations": "include"}) seen = set(str(d.get("id")) for d in shown) hidden = [d for d in every if str(d.get("id")) not in seen] print("① 화면 기본값 그대로 (active_registrations=only) : %d 줄" % len(shown)) print("② 등록 0개인 줄까지 (active_registrations=include) : %d 줄" % len(every)) print(" ─────────────────────────────────────────────────────") print(" 차이 = 화면이 숨기고 있는 기기 줄 : %d 줄" % len(hidden)) for d in hidden[:40]: who = (d.get("last_seen_user") or d.get("user") or {}).get("email") or "-" print(" %-36s %-24s %s" % (str(d.get("id"))[:36], str(d.get("name") or "-")[:24], who)) # 등록 쪽에도 같은 함정이 있습니다. 이쪽 기본값은 status=active 라 「취소된 줄」이 안 보입니다. act = get_all("/devices/registrations") allr = get_all("/devices/registrations", {"status": "all"}) print("\n③ 등록 status 기본값(active) : %d 건" % len(act)) print("④ 등록 status=all : %d 건" % len(allr)) print(" 차이 = 취소(revoke)된 줄 : %d 건" % (len(allr) - len(act))) print(" 🔴 취소는 삭제가 아닙니다. 줄이 남아 있고 가상 IP 도 안 돌려줍니다.") print("\n화면으로 같은 것을 보려면 Team & Resources › Devices › Filters › Active registrations 를") print("「Include devices without active registrations」 로 바꿉니다 — 목록이 늘어납니다.")