#!/usr/bin/env python3 """단말 프로필을 **적용 순서대로** 펼칩니다 — 그리고 분할 터널 모드까지 (운영 매뉴얼 M12). 🟢 읽기만 합니다. 아무것도 바꾸지 않습니다. 표준 라이브러리만 씁니다 — 설치할 것이 없습니다. export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Zero Trust Read export CF_ACCOUNT_ID=<계정 ID> python profiles.py 왜 순서가 중요한가 — 프로필은 **위에서 아래로 첫 일치**입니다. 한 번 맞으면 아래는 보지 않고, 아무 조건에도 안 맞은 단말은 **맨 아래 Default** 가 받습니다. 🔴 그래서 「프로필을 고쳤는데 그 단말만 안 바뀐다」의 대부분은 **그 단말이 그 프로필을 안 받은 것**입니다. 고치기 전에 순서와 조건(match)을 먼저 봅니다. 반영에는 최대 10분이 걸립니다. 🔴 **Include 와 Exclude 는 방향이 반대입니다.** Exclude(기본) = 적어 둔 것만 빠지고 나머지 전부가 Gateway 로 갑니다. Include = 적어 둔 것만 Gateway 로 가고 나머지는 전부 지나칩니다. 그래서 Include 프로필에서 「우회시켜 달라」는 요청은 **등록할 것이 없습니다** — 그 주소를 목록에 넣으면 오히려 터널을 타게 됩니다. 공식 문서 프로필 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/policies/methods/list/ 분할 터널 https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/cloudflare-one-client/configure/route-traffic/split-tunnels/ 프로필 문서 https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/cloudflare-one-client/configure/device-profiles/ """ import json import os import sys import urllib.error import 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 call(path, quiet=False): url = API + "/accounts/" + ACCOUNT + path req = urllib.request.Request(url) req.add_header("Authorization", "Bearer " + TOKEN) try: with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: if quiet: return None raw = e.read().decode("utf-8", "replace") try: msg = json.dumps(json.loads(raw).get("errors"), ensure_ascii=False) except ValueError: msg = raw[:200] sys.exit("GET %s → HTTP %d %s" % (path, e.code, msg)) def split_tunnel(pid): """그 프로필의 분할 터널. 🔴 include 가 비어 있지 않으면 **Include 모드**입니다. Default 프로필은 policy_id 가 없는 경로(/devices/policy/include)로 물어봅니다. """ base = "/devices/policy" + ("/" + pid if pid else "") inc = call(base + "/include", quiet=True) or {} exc = call(base + "/exclude", quiet=True) or {} inc_n = len(inc.get("result") or []) exc_n = len(exc.get("result") or []) mode = "Include" if inc_n else ("Exclude" if exc_n else "(못 읽었습니다)") return mode, inc_n, exc_n profiles = (call("/devices/policies") or {}).get("result") or [] # precedence 가 작을수록 위입니다. Default 는 precedence 가 없고 항상 맨 아래입니다. ordered = sorted( profiles, key=lambda p: (1 if p.get("default") else 0, p.get("precedence") if p.get("precedence") is not None else 10 ** 9), ) print("단말 프로필 %d개 — 위에서 아래로 첫 일치, Default 는 맨 아래\n" % len(ordered)) print("%-4s %-26s %-7s %-10s %-22s %s" % ("순서", "이름", "켜짐", "모드", "분할터널", "조건(match)")) print("-" * 108) for i, p in enumerate(ordered, 1): pid = "" if p.get("default") else str(p.get("policy_id") or "") mode, inc_n, exc_n = split_tunnel(pid) st = "%s (포함 %d · 제외 %d)" % (mode, inc_n, exc_n) print("%-4s %-26s %-7s %-10s %-22s %s" % ( "기본" if p.get("default") else str(p.get("precedence") if p.get("precedence") is not None else i), str(p.get("name") or ("Default" if p.get("default") else p.get("policy_id")))[:26], "예" if p.get("enabled", True) else "아니오", str(p.get("service_mode_v2", {}).get("mode") if isinstance(p.get("service_mode_v2"), dict) else "-")[:10], st, (str(p.get("match") or "-"))[:40])) print("\n읽는 법") print(" · 위에서부터 **첫 일치**입니다. 어떤 단말이 위쪽 프로필 조건에 맞으면 아래는 안 봅니다.") print(" · 조건(match)이 비어 있고 Default 로 찍힌 줄이 **아무 조건에도 안 맞은 단말**을 받습니다.") print(" · 「모드」는 클라이언트 동작 방식(service_mode_v2)입니다 — warp / posture_only 등.") print(" · 분할 터널이 **Include** 인 프로필에서는 목록에 없는 것이 이미 전부 우회입니다.") print("\n🔴 설정을 고쳐도 최대 10분 걸립니다. 그리고 MDM 으로 배포한 단말은 화면 설정이 아니라") print(" MDM 파일이 이깁니다 — 그쪽도 함께 고쳐 재배포해야 합니다.")