#!/usr/bin/env python3 """등록을 지우기 전후로 Zero Trust 설정을 통째로 받아 비교합니다 (운영 매뉴얼 M5). 🟢 읽기만 합니다. 아무것도 바꾸지 않습니다. export CF_API_TOKEN=<읽기 전용 토큰>; export CF_ACCOUNT_ID=<계정 ID> python snapshot.py before # ① 지우기 전 # …여기서 등록을 지웁니다… python snapshot.py after before # ② 지운 뒤 + 비교 🔴 이것을 `diff` 로 직접 하면 **거의 반드시 틀립니다.** updated_at · last_successful_login · last_seen 은 아무것도 안 건드려도 흐릅니다. 그대로 비교하면 「사용자가 바뀌었다」가 나오고, 그러면 이 절의 결론 (설정은 그대로다)이 통째로 뒤집혀 보입니다. 그래서 그 필드들을 빼고 비교하고, **뺀 목록을 화면에 함께 찍습니다** — 무엇을 안 셌는지 감추면 그것대로 거짓말입니다. 공식 문서 정책 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/policies/methods/list/ 그룹 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/groups/methods/list/ 앱 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/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 를 환경변수로 넣어 주세요.") if len(sys.argv) < 2: sys.exit("사용법: python snapshot.py <받을 폴더> [비교할 폴더]") OUT = sys.argv[1] DIFF = sys.argv[2] if len(sys.argv) > 2 else None # 「바뀌지 않아야 하는」 설정 11종. STABLE = [ ("access-users", "/access/users"), ("access-policies", "/access/policies"), ("access-groups", "/access/groups"), ("access-apps", "/access/apps"), ("access-idps", "/access/identity_providers"), ("access-tokens", "/access/service_tokens"), ("devices-profiles", "/devices/policies"), ("devices-posture", "/devices/posture"), ("devices-settings", "/devices/settings"), ("gateway-rules", "/gateway/rules"), ("gateway-lists", "/gateway/lists"), ] # 「바뀌어야 하는」 대조군 2종. 이쪽이 안 움직였으면 애초에 아무것도 안 지운 것입니다. MOVING = [ ("devices-registrations", "/devices/registrations", {"status": "all"}), ("devices-physical", "/devices/physical-devices", {"active_registrations": "include"}), ] VOLATILE = {"updated_at", "last_successful_login", "last_seen", "last_seen_at", "last_used", "seen_at", "modified_on"} def get(path, params=None, paged=False): out, cursor = [], "" while True: q = dict(params or {}) if paged: q["per_page"] = "100" if cursor: q["cursor"] = cursor url = API + "/accounts/" + ACCOUNT + path + ("?" + urllib.parse.urlencode(q) if q else "") 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 paged: return j.get("result") out.extend(j.get("result") or []) nxt = (j.get("result_info") or {}).get("cursor") or "" if not nxt or nxt == cursor or not j.get("result"): return out cursor = nxt def strip_volatile(o): if isinstance(o, dict): return dict((k, strip_volatile(v)) for k, v in o.items() if k not in VOLATILE) if isinstance(o, list): return [strip_volatile(x) for x in o] return o def index(rows): if isinstance(rows, dict): return {"(단일 객체)": rows} return dict((str((r or {}).get("id") or (r or {}).get("name") or i), r) for i, r in enumerate(rows or [])) os.makedirs(OUT, exist_ok=True) got = {} for item in STABLE + MOVING: name, path = item[0], item[1] params = item[2] if len(item) > 2 else None try: got[name] = get(path, params, paged=name.startswith("devices-r") or name.startswith("devices-p")) except Exception as e: # 🔴 한 곳이 막혀도 나머지는 계속 받습니다. 여기서 통째로 죽으면 # 「비교할 수 없다」가 「바뀐 게 없다」로 둔갑합니다. got[name] = {"_error": str(e)[:120]} print(" ⚠️ %s — 못 받았습니다: %s" % (name, str(e)[:70])) with open(os.path.join(OUT, name + ".json"), "w", encoding="utf-8") as f: json.dump(got[name], f, ensure_ascii=False, indent=2, sort_keys=True) n = len(got[name]) if isinstance(got[name], list) else 1 print(" %-24s %s" % (name, n)) if not DIFF: print("\n받았습니다. 등록을 지운 뒤 한 번 더 받아 비교합니다:") print(" python snapshot.py after %s" % OUT) sys.exit(0) print("\n비교: %s → %s" % (DIFF, OUT)) print(" 시각 필드는 뺍니다(%s) — 그냥 흐르는 값이라" % ", ".join(sorted(VOLATILE))) print(" 그것까지 세면 「전부 바뀌었다」가 나와 결론이 통째로 틀려집니다.") moving_names = set(m[0] for m in MOVING) stable_changed, moving_changed = [], [] for item in STABLE + MOVING: name = item[0] old_path = os.path.join(DIFF, name + ".json") if not os.path.exists(old_path): continue old = json.load(open(old_path, encoding="utf-8")) new = got[name] if isinstance(old, dict) and old.get("_error") or isinstance(new, dict) and new.get("_error"): print(" %-24s 한쪽이 오류라 비교하지 못합니다" % name) continue a, b = index(old), index(new) canon = lambda o: json.dumps(strip_volatile(o), sort_keys=True, ensure_ascii=False) added = [k for k in b if k not in a] removed = [k for k in a if k not in b] changed = [k for k in b if k in a and canon(a[k]) != canon(b[k])] total = len(added) + len(removed) + len(changed) mark = "그대로" if not total else "+%d / -%d / 변경 %d" % (len(added), len(removed), len(changed)) flag = "" if total and name not in moving_names: flag, _ = " 🔴", stable_changed.append(name) elif total: flag, _ = " ← 여기는 바뀌어야 정상입니다", moving_changed.append(name) print(" %-24s %4d → %4d %s%s" % (name, len(a), len(b), mark, flag)) for k in added[:5]: print(" + " + k) for k in removed[:5]: print(" - " + k) print("") if not moving_changed: print("⚠️ 단말 쪽도 안 바뀌었습니다 — 두 스냅샷 사이에 아무것도 안 지운 것입니다.") elif not stable_changed: print("🟢 설정 11종 전부 그대로. 바뀐 것은 단말뿐입니다. 이것이 M5 입니다 —") print(" 정책·그룹은 사용자·기기 객체가 아니라 이메일·도메인·IdP 그룹 조건으로 쓰여 있습니다.") print(" 조건에 적힌 값은 그 사용자를 지워도 남습니다.") print(" → 정리는 두 갈래입니다: ① 단말·기기 정리 ② 이메일이 직접 적힌 정책·목록에서 그 줄 빼기.") else: print("🔴 바뀌면 안 되는 것이 바뀌었습니다: " + ", ".join(stable_changed)) print(" 등록 삭제만으로는 이렇게 되지 않습니다. 같은 시간에 다른 작업이 끼어들었는지") print(" 감사 로그(actor.email · interface)를 보십시오.")