실습 계정 만들기 운영자?승인 단계는 없습니다(2026-09-11). 신청하면 바로 쓸 수 있습니다 — 운영자가 손대지 않아도 로그인이 됩니다.
관리 화면 /lab/admin 은 그대로 있습니다. 거기서 하는 일은 바뀌었습니다: 명단 보기 · 차단(막을 주소) · 강제 삭제 · 보여 줄 카테고리 조정. 🔴 「대기·승인」 목록은 없습니다 — 신청이 곧 승인이라 대기 상태가 생기지 않습니다. 「승인」 버튼은 차단해 둔 줄을 되살릴 때만 나타납니다. 문의는 API 로만 읽습니다(관리 화면에 그 칸이 없습니다).
문은 이제 자리 수입니다 — 동시 20명. 21번째는 거절되고, 1주일 로그인 없는 계정이 자동 정리되면서 자리가 납니다.
읽기만 해서는 Zero Trust 로그인이 어떤 것인지 감이 안 옵니다.
자기 이메일로 직접 한 번 로그인해 보는 것이 이 실습장의 요지입니다.
비밀번호는 만들지 않습니다 — 로그인할 때마다 그 주소로 여섯 자리 코드가 갑니다.
1여기에 이메일을 넣고 신청합니다.
2승인은 없습니다 — 신청하면 바로 쓸 수 있습니다.운영자?가입은 「우리 명단」 만 바꿉니다. Cloudflare Access 정책에 이메일을 넣지 않습니다.
대신 로그인할 때마다 Access 가 우리 워커에 물어봅니다(External Evaluation). 그래서 Cloudflare 대시보드에서 Access 정책을 열어 봐도 가입자 목록이 안 보이는 것이 정상입니다 — 고장이 아닙니다.
이렇게 한 이유: 가입·삭제 때마다 Cloudflare 설정을 쓰지 않으려는 것입니다. 설정 변경이 0건이면 드리프트도 0건입니다.
3「로그인」을 누르면 코드가 오고, 통과하면 Zero Trust 사용자로 잡힙니다.
🔵 로그인하면 화면이 「Zero Trust 로그인」 하나로 좁혀집니다 —
그 실습을 하려고 로그인하는 것이라서입니다. 다른 기능은 로그인 없이 보시면 됩니다.
🕒 1주일 동안 로그인이 없으면 계정과 좌석이 자동으로 정리됩니다.
지워져도 언제든 다시 신청하시면 됩니다 — 자리를 서로 돌려 쓰기 위한 것입니다.
정말 진짜인가요?
네. 룰은 Cloudflare 존에 실제로 올라가고, 요청은 존 밖에서 엣지로 쏩니다.
결과에 붙는 Ray ID 로 Cloudflare 로그에서 그 요청을 그대로 찾을 수 있습니다.
다만 여기서 만드는 룰은 호스트 하나(lab.mzc.cflare.kr)에만
걸리도록 서버가 강제로 감쌉니다 — 무엇을 쓰셔도 다른 서비스에는 닿지 않고, 30분 뒤 자동으로 정리됩니다.
만든 곳: 메가존클라우드 Cloudflare 팀.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Security › Security rules › Create rule › Custom rules새 커스텀 룰 화면. 필드·연산자·값으로 조건을 만들면 아래 Expression Preview 에 식이 그대로 보입니다.같은 화면 › Then take action › Choose action고를 수 있는 동작 목록입니다. 처음에는 Log 로 지켜본 뒤 Block 이나 챌린지로 바꿉니다.
① 룰을 씁니다
② 시험 결과
요청 여러 개를 이 룰에 통과시킵니다
왼쪽에 룰을 쓰고 시험 발사를 누르면, 어떤 요청이 막히고 어떤 요청이
지나가는지 여기 나옵니다. 막히면 안 되는 요청도 함께 쏩니다 — 과탐지가 진짜 사고입니다.
③ 실제로 쏴 봅니다 이 룰이 걸린 진짜 주소입니다 — curl 로 쏴도 같습니다
룰을 시험 발사하면 여기에 주소가 나옵니다.
④ 진짜 Cloudflare 를 통과시킵니다
AWS EC2 오리진까지 — 관리형 룰셋·봇 관리가 실제로 평가됩니다
위 ③의 경로를 같은 EC2 오리진을 보는 호스트 세 곳에 쏩니다 —
한쪽은 WAF 룰이 걸려 있고 한쪽은 없습니다. 두 응답의 차이가 곧 Cloudflare 가 한 일이고,
진짜 Ray ID 가 남습니다.
⑤ 지금 존에 올라가 있는 룰
진짜 Cloudflare 존의 현재 상태입니다
새로 읽기를 누르면 지금 이 순간
진짜 존에 올라가 있는 실습 룰이 그대로 나옵니다.
사례실제 문의에서 나온 것 6건 — 제목을 누르면 창으로 열립니다
이렇게 보입니다룰이 활성으로 보이고 조건도 맞아 보이는데, 30일치 이벤트에서 그 룰로 잡힌 요청이 0건입니다.
왜 그런가요조건식에 지금은 동작하지 않는 필드를 썼기 때문입니다. 대표적으로 위협 점수 필드는 은퇴해서 항상 0 이라, 「점수가 N 이상이면 차단」은 영원히 참이 되지 않습니다. 룰이 존재하는 것과 동작하는 것은 다릅니다.
이렇게 합니다룰이 살아 있는지는 화면이 아니라 이벤트 수로 봅니다. 룰 ID 로 최근 30일 이벤트를 세어 0건이면 조건식부터 의심하고, 은퇴한 필드는 봇 점수·관리형 룰셋처럼 살아 있는 축으로 바꿔 씁니다.
여기서 해 보기 — 「WAF 룰」 탭에서 조건을 걸고 쏴 보면 걸리는 룰은 로그에 즉시 남습니다. 안 남으면 평가되지 않은 룰입니다.
이렇게 보입니다관리형 룰셋과 커스텀 룰이 모두 적용됐다고 들었고 화면도 활성인데, 요청이 하나도 막히지 않고 원본 서버까지 갑니다. 보안 이벤트에는 기록만 쌓입니다.
왜 그런가요룰이 적용된 것과 막는 것은 다릅니다. 룰에는 동작(action)이 따로 있고, 도입 초기에는 정상 트래픽 오차단을 먼저 거르려고 기록만 하는 동작으로 켭니다. 이때 걸린 요청은 이벤트로 남고 통과합니다. 룰 이름에 PoC·테스트 표시가 있으면 한시적 구성일 가능성이 높습니다.
이렇게 합니다인수인계 문서나 안내문에 「적용」만 있으면 동작을 되묻습니다. 기록이면 언제 무엇을 기준으로 차단으로 바꿀지 정해 두십시오. 안 정하면 관찰 상태가 상시 구성이 됩니다. 전환 전에 이벤트를 며칠 쌓아 정상 트래픽이 얼마나 걸리는지 봅니다.
여기서 해 보기 — 「WAF 룰」 탭에서 같은 조건을 기록·차단 동작으로 각각 걸고 쏴 보십시오. 기록은 로그만 남고 응답이 오며, 차단은 403 이 옵니다. 화면에서는 둘 다 «활성»입니다.
이렇게 보입니다「9-7-1」처럼 하이픈이 든 정상 주소는 403 으로 막히고, 「9/7/1」·「9.7.1」은 통과합니다. 이벤트에는 관리형 룰이 아니라 공격 점수(WAF Attack Score) 기준 커스텀 룰이 막았다고 나옵니다.
왜 그런가요공격 점수는 정규식이 아니라 머신러닝 분류입니다. 문장부호 하나로 SQLi 점수가 크게 달라질 수 있고, 원인 글자를 짚어 줄 매치 패턴이 없습니다. 점수는 낮을수록 공격 가능성이 높아서, 정상 입력이 한 자릿수를 받으면 기준값을 30에서 20으로 낮춰도 막힙니다.
이렇게 합니다막힌 요청의 SQLi·XSS·RCE 점수를 각각 봅니다. 한 분류만 낮으면 그 오탐입니다. 그 경로(와 메서드)만 점수 룰에서 빼고, 같은 경로·점수 조건은 log 동작 룰로 따로 기록합니다. 뺀 경로도 관리형 룰은 계속 검사합니다. 데이터가 쌓이면 그 경로 전용 기준값을 정하고, 예외를 최종 형태로 두지 않습니다. 미리보기 → 제출처럼 요청이 이어지면 다음 단계 경로에서 다시 막히는지도 봅니다.
여기서 해 보기 — 「WAF 룰」 탭에서 공격 점수 조건을 걸고 하이픈·슬래시·점만 다른 값을 보내면 요청마다 점수가 달라집니다.
이렇게 보입니다관리형 룰셋의 한 룰이 차단(Block)·활성이고 대상 호스트도 배포 범위에 있는데, 며칠 전 그 룰에 걸린 요청은 보안 이벤트에 기록(Log) 으로 남고 통과했습니다.
왜 그런가요이벤트의 동작은 요청이 평가된 시점의 동작이지 지금 화면의 동작이 아닙니다. 관리형 룰셋을 기본(Default) 동작으로 배포하면, Cloudflare 가 업데이트로 룰의 기본 동작을 바꿀 때 우리 쪽 변경 없이 따라갑니다. 새 룰이나 강화된 룰은 기록으로 들어왔다가 뒤에 차단으로 올라가기도 합니다. 그래서 도메인 누락이나 예외 문제가 아닐 수 있습니다.
이렇게 합니다① 이벤트 시각과 WAF 변경 이력(changelog)을 맞춰 봅니다. 그 룰의 기본 동작이 바뀐 날짜가 이벤트보다 뒤면 그것으로 설명됩니다. ② 배포 범위·예외는 그다음입니다. 대상 호스트가 범위에 있는지, 예외 룰이 먼저 건너뛰게 하는지 봅니다. ③ 기본 동작을 따를지 정합니다. 룰마다 동작을 직접 지정(override)할 수 있지만, 그러면 이후 강화도 자동으로 받지 못합니다.
이렇게 보입니다감독기관이나 보안 협의체가 전파한 자동화 공격 IP 가 Cloudflare 소유 대역입니다. 「막으면 사이트와 오리진 사이 통신이 끊기지 않나」, 「대역째 막는 편이 안전하지 않나」에서 판단이 멈춥니다.
왜 그런가요Cloudflare 소유 IP 가 모두 CDN 엣지 IP 는 아닙니다. 엣지(프록시) IP 는 공식 목록에 따로 공개돼 있고, 일부 대역은 WARP 같은 프록시 계열 서비스의 사용자 출구 IP 입니다. 공격자가 이런 서비스를 거쳐 도구를 돌리면 피해 쪽 로그에 출구 IP 가 남고 그대로 「공격자 IP」로 전파됩니다. 출구 IP 는 여러 사용자가 함께 쓰므로, /32 하나를 막아도 엣지↔오리진 통신에는 영향이 없지만(엣지 IP 가 아니므로) /24·/16 대역으로 넓히면 같은 서비스를 쓰는 일반 이용자가 대량으로 막힙니다. 실제 측정에서 같은 대역의 다른 IP 들로부터 한 주에 수백만 건이 들어왔고 대부분 일반 모바일 이용자 모습이었습니다. IP 만으로는 어느 서비스를 썼는지, 사람인지 도구인지 특정할 수 없고, 목록의 국가 표기는 출구 IP 의 등록 위치일 뿐입니다.
이렇게 합니다① 엣지 IP 목록부터 대조합니다. 공식 엣지 목록에 없으면 막아도 엣지↔오리진 통신은 끊기지 않습니다. ② 우리 존에 들어온 적이 있는지 봅니다. 보안 분석 화면에서 출발지 IP 로 걸러 요청 수와 보안 이벤트 수를 확인합니다. 0건이면 차단해도 측정 범위에서 영향이 없습니다. 이벤트 보존 기간이 지나면 화면이 비니 전파 직후에 확인하십시오. ③ /32 단위로, 기간을 정해 막습니다. 계정 수준 IP 목록에 넣고 차단 룰 하나가 참조하게 하면 전 존에 한 번에 적용되고, 해제는 목록에서 빼면 됩니다. 대역으로 넓히지 마십시오. ④ 식별 경위를 물으면 추정과 확인을 나눠 답합니다. 출구 IP 의 성격은 공개 자료로 확인되지만 어느 서비스를 거쳤는지는 IP 만으로 확정할 수 없습니다. 더 필요하면 Cloudflare 남용 신고 경로로 확인을 요청합니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Security › Web assets › OperationsCloudflare 가 찾아 저장한 API 엔드포인트 목록입니다. 위쪽 탭에서 Sequences, Schema validation 으로 넘어갑니다.
API Shield — 규격을 어긴 요청만 골라 막습니다
WAF 룰이 「이런 모양이면 나쁘다」를 적는 것이라면, API Shield 의 스키마 검증은
「이 API 는 이렇게 생겼다」를 적어 두고 거기서 벗어난 것을 전부 막습니다.
공격 패턴을 하나씩 쫓지 않아도 되는 것이 차이입니다.
지금 걸려 있는 것
규격 하나에 엔드포인트 두 벌이 걸려 있고, 조치 단계만 다릅니다.
규격이 같으니 결과가 갈리면 그 원인은 조치 하나뿐입니다.
표적
조치
규격을 어기면
/apishield/orders/{orderId}
none
아무 일도 없습니다 — 그냥 지나갑니다
/apishield/strict/orders/{orderId}
block
403 으로 막히고 오리진에 닿지 않습니다
규격이 정한 것 — orderId 는 정수이고 1~999999,
currency 는 KRW·USD 만. 어느 회사의 것도 아닌 일반적인 주문 조회 모양입니다.
🔵 판정은 저희가 하지 않습니다. 이 화면은 두 표적에 같은 요청을 쏘고 돌아온 상태 코드와
Ray ID 를 그대로 옮길 뿐이고, 403 을 만든 것은 진짜 Cloudflare API Shield 입니다.
404 는 통과라는 뜻입니다 — 표적 경로에 실제 파일이 없어 오리진이 그렇게 답합니다.
Cloudflare 가 막았다면 오리진까지 가지도 못합니다.
여기서 못 해 보는 것 — 그리고 그 이유
API Shield 는 엔타이틀먼트가 존 단위입니다. 이 실습 존에서 되는 것과 안 되는 것이 갈립니다
(2026-09-16 저희 존 네 곳을 조회해 확인했습니다).
기능
이 실습장
엔드포인트 관리 · 스키마 검증
됩니다
지금 보고 계신 것이 그것입니다
세션 식별자 · API 디스커버리
안 됩니다
구성(configuration) 조회가 10403 입니다
BOLA · 볼류메트릭 남용 탐지
안 됩니다
세션 식별자에 기대는 기능이라 같이 막힙니다
Sequence 분석 · 완화
안 됩니다
공개 API 에 경로 자체가 없습니다 — 대시보드 베타로만 열립니다
「무엇이 왜 안 되는가」까지가 검토 결과입니다. 고객 존에서 되는지는
그 존에서 /api_gateway/configuration 을 조회해 10403 이
오는지로 판별합니다.
사례실제 문의에서 나온 것 5건 — 제목을 누르면 창으로 열립니다
이렇게 보입니다API 규격서를 올려 스키마 검증을 기록 동작으로 며칠 돌려 위반이 거의 없어 차단으로 올렸더니, 그날부터 특정 기능의 정상 요청만 막힙니다. 다른 사용자는 멀쩡해 룰 조건이나 봇 판정을 먼저 의심하게 됩니다.
왜 그런가요규격서를 실제 트래픽이 아니라 요청 샘플 모음에서 변환하면 샘플에 없던 값이 허용 목록에서 빠지고, 그 값의 정상 요청이 규격 위반이 됩니다. 기록 동작에서는 로그 한 줄로만 남고, 드문 선택지일수록 위반이 적어 «거의 없다»로 읽힙니다. 그래서 관찰을 길게 해도 안 드러나다가 차단하는 순간 한꺼번에 나타납니다.
이렇게 합니다차단 전에 쌓인 위반을 필드별로 집계해 어느 필드의 어떤 값이 걸렸는지 봅니다. 공격은 양으로, 규격 누락은 소수로 나타나므로 건수가 적은 위반일수록 드문 정상 값일 가능성이 큽니다. 값 목록을 고치면 운영·검증 환경 규격서를 함께 갱신하십시오. 한쪽만 고치면 검증에서 통과한 요청이 운영에서 막힙니다. 현재 버전은 규격서 업로드만으로는 막지 않고 탐지 결과를 커스텀 룰로 적용할 때 막으므로, 적용 전에 탐지 결과를 검토할 수 있습니다.
이렇게 보입니다«정해진 화면을 거친 요청만 통과»하는 순서 검증을 걸었습니다. 개발자 도구로 따라가면 통과하는데 실제 이용자 일부가 꾸준히 막힙니다. 같은 화면을 같은 순서로 거쳤다고 해서 재현이 안 되고, 봇 판정이나 캐시를 먼저 의심하게 됩니다.
왜 그런가요정상 이용자를 끊는 자리는 거의 둘 중 하나입니다. ① 「직전 한 번」만 보는 조건: 지나온 기록 배열의 맨 앞 한 칸만 비교하면, 관문과 목표 호출 사이에 요청이 하나만 끼어도 거짓이 됩니다. 광고·분석 스크립트, 자동 갱신, 이미지·부가 정보 호출처럼 이용자가 의식하지 않는 요청이 흔히 끼고, 개발자 시험에는 이런 호출이 없어 실사용에서만 납니다. ② 너무 좁은 시간 창: 「관문 뒤 N초 안」으로 제한하면 날짜·수량·옵션 선택, 다른 탭 보기, 느린 모바일 조작이 먼저 걸립니다. 이런 지체는 분포의 꼬리라 평균만 보면 창이 넉넉해 보입니다.
이렇게 합니다두 값은 감이 아니라 측정으로 정합니다.① 배열은 「전체 검사」로: 기억된 기록 전체에 관문이 있는지 보게 씁니다. 기억하는 칸 수에 상한이 있으니 관문과 목표 사이에 호출이 몇 개 끼는지 실제 트래픽에서 세어 확인하십시오. ② 시간 창은 실측 분포로: 기록 동작으로 며칠 관문 → 목표 호출 간격을 모아 평균 대신 상위 백분위(95·99번째)로 정합니다. 막힌 사람은 불만 없이 이탈하므로 문의 건수로는 피해를 알 수 없습니다. ③ 차단 전에 반드시 기록 동작을 거칩니다. 목적은 «정상인데 막을 뻔한 것이 얼마나 되나»를 보는 것이고, 매출 경로라면 더욱 그렇습니다. ④ 환경마다 값이 다릅니다. 운영으로 옮길 때 호스트 이름과 엔드포인트 식별자를 함께 바꾸십시오. 그대로 올리면 룰이 조용히 아무 일도 안 하거나, 허용 조건이 전부 거짓이 되어 정상 요청이 전량 막힙니다.
이렇게 보입니다등록한 API 만 통과시키고 새 API·미승인 API 는 막으려고 fallthrough 룰(저장되지 않은 요청을 잡는 룰) 을 호스트 조건만 넣어 걸었더니, 같은 호스트의 일반 웹 요청(HTML·JS·이미지)까지 «미등록»으로 걸려 사이트가 먹통이 됩니다.
왜 그런가요이 룰은 «요청이 저장된 엔드포인트 목록에 없는가»를 봅니다. 웹 페이지와 정적 자산은 엔드포인트로 등록하지 않으므로, 호스트만 조건이면 그 호스트 요청이 전부 «미등록»입니다. 웹과 API 가 한 호스트에 있으면 정상 트래픽이 통째로 걸립니다. 룰 이름에 API 가 있어도 조건에 맞으면 웹 요청까지 평가합니다.
이렇게 합니다① 조건에 경로를 더합니다. URI Path 가 API 전용 접두(/api, /v1 등)로 시작하는지 넣어 웹 트래픽을 뺍니다. ② 공식 템플릿에서 시작합니다. Security rules 의 Templates 에서 «Mitigate API requests to unidentified endpoints» 를 고르면 조건식(cf.api_gateway.fallthrough_detected)이 짜여 있습니다. ③ 기록(Log)으로 먼저 배포합니다. Security > Events 에서 통과했어야 할 요청이 잡히는지 보고, 문제없으면 차단(Block)으로 올립니다. ④ 통과시킬 API 는 미리 저장(save)합니다. 엔드포인트 관리(Web Assets > Operations)에 저장된 것만 «등록된» 요청입니다. 규격서를 올리면 함께 저장되고, 자동 탐지(디스커버리)분은 검토 뒤 저장합니다.
여기서 해 보기 — 이 탭의 두 표적에 같은 규격 위반 요청을 쏴 보면 조치 없음과 차단의 차이가 상태 코드로 나옵니다. 차단 전 관찰 단계가 왜 필요한지 확인할 수 있습니다.
이렇게 보입니다«API 를 전부 지키자»는 요구로 전체 규격서를 한꺼번에 올렸습니다. 검증 깊이가 모두 같아 관리 목록은 늘고, 환경 차이로 생긴 위반이 쌓이며, «이 API 에 스키마를 올려야 하나»를 매번 다시 논의합니다.
왜 그런가요규격서는 실제 호출과 완전히 같지 않아 환경 차이나 빠진 파라미터가 «정상 요청인데 위반»이 됩니다(같은 탭 «스키마 검증을 차단으로 바꾼 날부터…» 사례). 반면 경로와 메서드만 등록하고 «거기 없는 것»을 막는 방식은 규격서가 필요 없어 부담이 작습니다. 검증 깊이를 위험도에 따라 나누는 것이 현실적입니다.
이렇게 합니다세 등급으로 나눠 깊이를 달리합니다.① 핵심 API: 로그인·결제·회원가입·개인정보 조회 같은 고위험 경로만 규격서로 정밀 검증합니다. OpenAPI v3.0(JSON·YAML)으로 올려 위반을 로그로 관찰하다 문제없으면 차단합니다. 현행 스키마 검증은 OpenAPI v3.0 기준이고 Swagger 2.0/OpenAPI 2.0 은 받지 않으며, v3.1 은 v3.0 호환 범위에서만 올라갑니다. 지원 범위는 공식 문서로 확인하십시오. ② 일반 API: 규격서 없이 엔드포인트 관리(Web Assets > Operations)에 경로·메서드만 저장하고 fallthrough 룰로 «저장되지 않은 요청»을 잡습니다. ③ 신규·미확인 API: 자동 탐지(디스커버리)와 로그로 관찰하다 검증이 끝나면 ①·②로 올립니다.
여기서 해 보기 — 이 탭에서 orderId 를 정수가 아닌 값으로, currency 를 KRW·USD 밖의 값으로 바꿔 쏴 보면 정밀 검증이 무엇을 잡는지 보입니다.
이렇게 보입니다API Shield 담당자에게 권한을 주려는데 목록이 «모두 아니면 없음»처럼 보여 계정 관리자 권한을 통째로 줄지 고민됩니다. WAF 룰·캐시·DNS 까지 함께 열리는 구성이라 보안 심사에서 지적되기도 합니다.
왜 그런가요권한은 역할(role) 단위이고, 대표 관리 역할 하나가 WAF·캐시 같은 여러 설정을 함께 포함합니다. 그래서 관리자급이 필요해 보이지만 API Shield 용 별도 역할이 이미 있습니다. 메뉴 깊숙이 있어 못 찾는 경우가 많습니다.
이렇게 합니다구성원 초대·권한 편집(Members)에서 App Security 자리의 역할을 고릅니다. API Gateway 는 API Gateway·API Shield 설정 전체를 수정할 수 있고, API Gateway Read 는 조회만 됩니다. 도메인(존) 단위로 좁히려면 Domain API Gateway / Domain API Gateway Read 를 씁니다. 이러면 API Shield 관리 권한만 주고 CDN·WAF 등 다른 설정의 수정 접근은 닫아 둡니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Security › Security rules › Create rule › Rate limiting rules조건, 무엇으로 셀지(With the same characteristics), 몇 번을 넘으면 막을지를 한 화면에서 정합니다.같은 화면 › With the same characteristics세는 기준 목록입니다. 사무실처럼 여러 사람이 한 IP 를 쓰면 IP with NAT support 나 헤더·쿠키 값을 고릅니다.
속도 제한 — 몇 번째부터 막히나표적 호스트로 연속 발사
🔴 이 발사는 실습장과 「같은 존」 안에서 나갑니다 — 그 존의 속도 제한·WAF 를 타지 않습니다.
여기서 「안 막혔습니다」가 나와도 실제로는 막힐 수 있습니다. 진짜 판정은 「WAF 룰」 탭의 「진짜로 판정」
(존 밖 발사)으로 보고, 속도 제한 자체는 바깥에서 curl 로 확인합니다.
같은 요청을 연속으로 쏴서 몇 번째 요청부터 상태코드가 바뀌는지를 봅니다.
속도 제한은 「임계값 + 기간 + 무엇으로 세는가(특성)」 셋으로 정해집니다 —
IP 로 세는지 JA4 로 세는지에 따라 같은 발사가 전혀 다르게 보입니다.
발사하면 회차별 결과가 여기 나옵니다.
사례실제 문의에서 나온 것 2건 — 제목을 누르면 창으로 열립니다
이렇게 보입니다속도 제한이 동작하는데 오류 페이지가 안 보입니다. 본문을 HTML 로 파싱하는 점검 도구는 「아무것도 안 왔다」로 처리합니다.
왜 그런가요기본 차단 응답은 HTML 페이지가 아니라 `error code: 1015` 한 줄짜리 평문이라, HTML 을 기대하면 빈 응답처럼 보입니다.
이렇게 합니다차단 여부는 본문이 아니라 상태 코드와 헤더로 판정합니다. 사람이 볼 화면이 필요하면 응답을 따로 지정합니다.
여기서 해 보기 — 「속도 제한」 탭에서 연속 발사하면 임계값을 넘는 순간부터 응답 모양이 그대로 보입니다.
이렇게 보입니다데이터센터발 자동화를 막으려고 특정 경로에 속도 제한을 걸었는데, 일반 이용자가 모바일에서 같은 화면을 열 때 429 를 받습니다. 로그상 그 주소의 요청은 몇 건뿐으로 제한값에 한참 못 미칩니다.
왜 그런가요핵심은 무엇을 「한 사람」으로 세느냐입니다. 속도 제한은 IP 말고도 브라우저 TLS 지문·접속 지역 같은 특성을 세는 단위로 고를 수 있고, 주소를 계속 바꾸는 자동화는 지문으로 세야 잘 잡힙니다. 그런데 지문은 사람 한 명을 가리키지 않습니다. 같은 운영체제·브라우저 버전이면 다른 이용자와 서버가 같은 지문을 갖습니다. 대상 목록에 데이터센터 사업자와 일반 통신사가 함께 있으면 두 쪽이 같은 카운터를 나눠 쓰고, 자동화가 채운 카운터에 이용자가 얹혀 몇 건 만에 걸립니다.
이렇게 합니다① 대상 범위에서 일반 이용자 통신망을 뺍니다. 자동화만 겨냥한 규칙이면 이것만으로 오차단이 사라지는 경우가 많습니다. ② 지문과 무관한 결정적 조건을 더합니다. 경로·질의 문자열·요청 방식처럼 자동화에만 해당하는 성질을 넣으면 지문이 겹쳐도 사람 요청은 들어오지 않습니다. ③ 잘 돌고 있는 규칙을 넓히지 말고 새 규칙을 좁은 조건으로 만듭니다. 넓히면 지금 막는 것까지 흔들리고, 새 규칙은 되돌리기 쉽습니다. ④ 바꾸기 전에 걸린 요청을 통신망별로 나눠 봅니다. 자동화와 이용자가 섞였는지 바로 드러납니다.
여기서 해 보기 — 실습장에서 세는 단위를 주소에서 지문 계열로 바꾸고 같은 브라우저로 창만 여러 개 띄워 요청해 보십시오. 창을 나눠도 카운터가 합쳐져 「조금밖에 안 보냈는데 걸린다」가 재현됩니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Security › Analytics › Bot analysis요청을 봇 점수 구간(Automated·Likely automated·Likely human)과 검증 봇으로 나눠 보여 줍니다.도메인 › Security › Settings › Bot traffic봇 관련 설정 모음입니다. Bot management 는 늘 켜져 있고, 차단은 커스텀 룰로 정합니다.
봇 판정 — 무엇을 보고 사람/봇을 가르나표적 호스트로 발사
🔴 이 발사도 「같은 존」 안에서 나가 그 존의 WAF·봇 룰이 평가되지 않습니다 — 전부 「통과」로
보이는 것이 정상입니다. 진짜 판정은 「WAF 룰」 탭의 「진짜로 판정」(존 밖 발사)으로 봅니다.
User-Agent 를 바꿔 가며 쏘고 판정이 달라지는지를 봅니다. 봇 점수 자체는 응답 헤더로 오지 않습니다 —
점수(1=확실한 봇 … 99=확실한 사람)와 검증된 봇 여부는 Security Events·Logpush 에서만 보이고,
Enterprise + Bot Management 에서만 쓸 수 있습니다. 그리고 판정은 UA 만으로 정해지지 않습니다 —
TLS 지문·행동·ASN 이 함께 들어갑니다. 그래서 UA 만 바꿔도 결과가 안 바뀔 수 있습니다.
발사하면 UA 별 판정이 여기 나옵니다.
사례실제 문의에서 나온 것 1건 — 제목을 누르면 창으로 열립니다
이렇게 보입니다콘텐츠 보호용으로 AI 봇 차단을 켜 두었는데, 어느 날 설정 화면 모양이 바뀌고 막히던 검색 크롤러가 통과합니다. 우리 쪽에서 바꾼 것은 없습니다.
왜 그런가요기본값과 설정 구조가 함께 바뀌었습니다. AI 크롤러 제어가 검색·학습·에이전트 세 축으로 갈렸고, 학습 크롤러 권장값이 「차단」에서 「학습에 쓰지 말라고 지시」로 바뀌었습니다. AI 봇 차단이나 관리형 robots.txt 를 켜 두었다면 자동으로 옮겨지며, 차단이었다면 검색은 허용·학습은 지시·에이전트는 광고가 있는 페이지에서 차단이 됩니다. 「학습에 쓰지 말라」는 차단이 아니라 요청이라 검색 크롤러는 들어와 색인합니다. 동작 변경은 즉시, 화면은 한 주에 걸쳐 나뉘어 배포되므로 「화면은 그대로인데 결과가 다른」 구간이 실제로 있습니다.
이렇게 합니다① 세 축의 현재 값을 확인합니다. 옮겨진 값은 예전 설정에서 기계적으로 정해진 것이지 누가 고른 값이 아닙니다. ② 검색 색인을 원하는지 정합니다. 검색 노출이 필요하면 옮겨진 값이 맞고, 아무도 들이지 않으려면 명시적으로 차단으로 되돌려야 합니다. 가만두면 허용입니다. ③ 설정한 적이 없으면 바뀌는 것이 없습니다. 이관은 켜 둔 설정이 있을 때만 일어납니다. ④ 배포 기간 중 손으로 바꾼 값은 보존됩니다. 화면이 예전 모양이어도 되돌려지지 않습니다.
여기서 해 보기 — 바꾸기 전에 최근 봇 트래픽을 크롤러별로 나눠 보십시오. 색인 크롤러가 얼마나 오는지 모른 채 「전부 막자」를 고르면 검색 유입이 사라진 뒤에야 알게 됩니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Caching › Cache Rules › Create rule자주 쓰는 세 가지(모두 캐시, 모두 우회, 기본 확장자만 캐시)는 템플릿으로 바로 시작합니다.
캐시 — 두 번째 요청이 어디서 오나cf-cache-status
같은 주소를 두 번 쏩니다. 첫 번째는 MISS(오리진까지 갔습니다),
두 번째가 HIT 면 엣지가 답한 것입니다. DYNAMIC 은
「캐시 대상이 아닙니다」라는 뜻이고, 쿼리스트링이 다르면 다른 주소로 봅니다.
발사하면 회차별 캐시 상태가 여기 나옵니다.
사례실제 문의에서 나온 것 3건 — 제목을 누르면 창으로 열립니다
이렇게 보입니다속도를 올리려고 캐시 예약(Cache Reserve)을 켰는데 며칠이 지나도 응답 시간 지표가 거의 그대로입니다. 95 백분위도 비슷하고 다른 CDN 과도 엎치락뒤치락합니다. 기능은 정상이고 적중률도 나쁘지 않습니다.
왜 그런가요이 기능이 줄이는 것은 오리진에 다시 묻는 횟수이지 에지 응답 속도가 아닙니다. 에지 캐시에서 밀려난 객체는 다음 요청 때 오리진까지 가는데, 캐시 예약은 그 객체를 붙잡아 두는 뒷단 저장소입니다. 빨라지는 것은 원래 오리진까지 갔을 요청뿐이라, 적중률이 이미 높고 안정적이면 평균이든 95 백분위든 분포가 흔들리지 않습니다. 효과가 큰 자리는 드물게 흩어지는 자산(롱테일), 오리진 요청 수와 부하, 오리진에서 나가는 트래픽 비용입니다. 또 이 기능은 계층형 캐시(Tiered Cache)와 함께 쓰도록 설계됐습니다. 없이 켜면 여러 에지가 각각 저장소에 써서 쓰기 비용이 늘 수 있고, 한쪽만 켠 기간의 숫자는 권장 구성의 숫자가 아닙니다.
이렇게 합니다① 성공 지표를 먼저 정합니다. 응답 시간이 아니라 오리진 요청 수·오리진에서 나간 트래픽량·적중률의 롱테일 구간으로 잡아야 변화가 보입니다. ② 지금 적중률을 봅니다. 이미 높고 안정적이면 응답 시간 이득은 거의 없다고 미리 말해 둡니다. ③ 관찰 중에는 캐시 설정을 건드리지 않습니다. 계층형 캐시를 끄거나 캐시 키 구성을 바꾸면 앞뒤가 다른 구성의 숫자가 됩니다. 부득이 바꿨다면 구간을 나눠 읽고 판단하는 사람에게 알립니다. ④ 비용은 절감액과 나란히 봅니다. 저장 용량과 읽기·쓰기 횟수로 과금되므로 「오리진 트래픽 절감액 − 예약 저장 비용」이 기준이고, 응답 시간은 이 계산에 들어가지 않습니다.
여기서 해 보기 — 적중률이 높은 자산과 낮은 자산을 같은 기간 비교해 보십시오. 낮은 쪽에서만 오리진 요청이 눈에 띄게 줄어듭니다. 응답 시간만 보면 둘 다 «변화 없음»입니다.
이렇게 보입니다장기 보관 캐시를 켜고 며칠 봤는데 다른 CDN 과 응답 시간이 거의 같습니다. 「속도 개선」이 명분이었다면 기대가 어긋납니다. 이벤트·적중률에는 이상이 없습니다.
왜 그런가요두 가지가 겹칩니다. 저장 위치는 고를 수 없습니다. 리전을 자동 할당하는 설계라 「원본 근처」·「이용자 근처」 지정이 없습니다. 또 원본이 한 지역에 있으면 뒤로 묻는 시간(origin duration)이 그 왕복만큼 깔립니다. 다른 CDN 도 같은 종류 상위 캐시를 같은 지역에 두면 조건이 같아 응답 시간이 비슷합니다. 설정 문제가 아니라 원본 위치가 지배적인 것입니다.
이렇게 합니다속도 지표로 평가하지 마십시오. 바뀌는 것은 원본으로 되돌아가는 요청 수·오래된 자산 보존·원본 부하이고, 이것을 지표로 잡아야 효과가 보입니다. 원본이 멀어 생기는 지연은 캐시 계층 구조(상위 캐시를 어디에 둘지)로 다룰 문제이고, 장기 보관 캐시로는 다뤄지지 않습니다. 평가 전에 원본 위치와 이용자 분포를 적어 두면 기대가 덜 어긋납니다.
여기서 해 보기 — 같은 자산을 원본과 가까운 지역·먼 지역에서 받아 비교해 보십시오. 차이 대부분이 뒤로 묻는 구간에서 나오면 캐시 종류를 바꿔 줄일 지연이 아닙니다.
이렇게 보입니다새 기능을 일정 기간 켜 두고 효과를 쟀는데 기대만큼 좋아지지 않아 도입을 접습니다. 그런데 그 기간 중간에 다른 설정 하나가 바뀌어 있었습니다. 측정 담당과 변경 담당이 달라 아무도 겹쳐 보지 않았습니다.
왜 그런가요재는 동안 전제가 바뀌면 그 수치는 그 기능의 성적표가 아닙니다. 캐시 계층은 서로 기대어 동작합니다. 상위 계층이 켜져야 제 성능이 나오는 기능을 꺼진 구간과 켜진 구간에 걸쳐 재면 두 구성을 한 평균으로 뭉갠 값이 나오고, 그 평균은 어느 구성도 대표하지 않습니다. 이 오류는 경고 없이 숫자와 그래프가 정상으로 나와 «기능이 별로였다»는 결론이 자연스럽게 섭니다. 되돌리기도 어렵습니다. 기능을 끄고 저장소를 지우면 그 기간 사용량·적중률을 다시 볼 수 없습니다.
이렇게 합니다① 시작할 때 고정돼야 할 설정을 적어 둡니다. 목록이 있으면 하나가 바뀌었을 때 바로 걸립니다. ② 기간 중 설정 변경 이력을 함께 뽑습니다. 측정 기간과 겹쳐 보면 구간이 갈렸는지 드러납니다. ③ 갈렸으면 평균 내지 말고 구간별 수치로 보고합니다. 구간 길이 차이가 크면 짧은 쪽은 표본이 얇다고 적습니다. 결론을 뒤집으라는 뜻은 아닙니다. 재평가는 그쪽이 정하고, 사실을 먼저 알리는 것이 이쪽 몫입니다. 알리지 않으면 잘못된 근거가 의사결정에 남습니다. 기능을 끄고 저장소를 지우기 전에 사용량과 객체 수를 조회해 남기십시오. 기간 정산의 유일한 근거이고 지운 뒤에는 조회할 수 없습니다.
여기서 해 보기 — 조회만으로 확인됩니다. 측정 기간의 시작·끝 시각과 같은 기간의 설정 변경 이력을 겹쳐 보고, 한 건이라도 걸리면 그 시각을 경계로 나눠 다시 집계합니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Rules › Overview › TemplatesHTTP→HTTPS, www→루트 같은 리다이렉트와 변환은 템플릿에서 시작하면 조건식을 쓰지 않아도 됩니다.
변환 · 리다이렉트 — 응답이 어떻게 바뀌나헤더 · 3xx
요청을 쏘고 돌아온 헤더와 상태코드를 그대로 보여 줍니다. 리다이렉트 룰이 걸려 있으면
3xx 와 location 이, 응답 헤더 변환이 걸려 있으면 그 헤더가 여기 나타납니다.
따라가지 않고 첫 응답을 그대로 봅니다 — 따라가면 무엇이 바꿨는지가 사라집니다.
발사하면 응답 헤더가 여기 나옵니다.
🧪 새 랩 — 소속별 접근 통제(PC·휴대폰): 본사·협력사·고객사 계정으로 직접 로그인해 같은 서버가 소속마다 열리고 막히는 것을 확인합니다.
처음이면 초급 안내부터 ↗
🧭 SSL-VPN 대체 — 비교 안내: SSL-VPN 과 Zero Trust 가 무엇이 어떻게 다르고, VPN 을 끄지 않으면서 무엇부터 옮기는지를 초급·중급·전문가 3단계로 정리했습니다.
비교 안내 보기 ↗
한눈에 보기 — 그림으로 따라가는 Zero Trust 로그인
처음 보는 사람용 · 여기는 읽는 곳이고, 실제로 눌러 보는 것은 ② 직접 해보기부터입니다
한 줄로 말하면: 회사 앱 앞에 Cloudflare 가 문지기로 서서, 「누구냐」(회사 로그인)와 「진짜 너냐」(휴대폰 6자리)를 물은 뒤에만 들여보냅니다.
아래 그림 7장이 그 문지기가 무엇을 언제 묻는지, 어디서 설정하는지, 막히면 어떻게 푸는지를 순서대로 보여 줍니다.
그림은 작게 줄이면 안 읽혀서 크기를 지켰습니다 — 옆으로 밀어서 보세요.
1큰 그림. 문지기는 두 번 묻습니다. ①은 회사가 이미 갖고 있는 로그인이고, ②가 이 실습에서 새로 켜는 「2단계 인증(MFA)」입니다.22단계는 두 군데서 걸 수 있습니다.A 는 회사 로그인을 건드리지 않고 Cloudflare 가 뒤에서 한 번 더 묻는 방식, B 는 회사 로그인 자체가 묻는 방식입니다.3사용자가 겪는 순서. 등록은 딱 한 번이고 그 문이 App Launcher 입니다. 빨간 갈림길이 Demo2 에서 실제로 막혔던 자리입니다 — 관리자가 먼저 켜 둬야 합니다.4설정은 세 층이 덮어씁니다. 처음 켤 때는 조직에서 수단만 허용하고, 앱 하나에만 걸어 시작하는 것이 안전합니다(전역 강제를 켜면 모든 앱이 한꺼번에 묻습니다).5잠겼을 때. 관리자가 할 일은 「지우기」 하나뿐입니다. 대신 등록해 주는 기능은 없고, 사용자가 다시 QR 을 찍습니다.6세션은 시계 네 개. 「WARP 를 켜 두면 왜 계속 유지되나」의 답이 첫 줄입니다. 재부팅에 맞춰 끊고 싶으면 단말 세션을 짧게 두고, MFA 는 매번 묻게 하고, Windows 라면 「미인증 시 인터넷 차단(no_auth_no_internet)」을 더합니다.7설치하면 「기기」를 알게 됩니다. 설치 안 하면 Cloudflare 가 보는 것은 그 앱으로 가는 「요청」 하나뿐이라, 기기 상태·사설망·인터넷 필터는 근거가 없어서 못 하는 것입니다. 능력 차이는 기능 목록이 아니라 도달 범위에서 나옵니다.
🔴 거꾸로, 「설치하지 않았으면 아예 못 들어오게」도 됩니다 — Access 정책의 Require 에 걸면 로그인 화면조차 통과하지 못합니다.
다만 WARP 로 걸면 개인용 WARP 를 켠 사설 노트북도 통과합니다. 「회사 PC 만」이 목적이면 Gateway 입니다 —
운영 매뉴얼 M17 에서 자세히 ↓
IdP 신분을 확인해 주는 곳 — 회사 로그인(AD FS·Okta·구글). 「누구냐」를 답합니다MFA · 2단계 비밀번호 말고 한 번 더 확인. 「진짜 너냐」TOTP · 인증 앱 30초마다 바뀌는 6자리를 만드는 휴대폰 앱(Google·Microsoft Authenticator)App Launcher 내가 들어갈 수 있는 앱 목록 화면. 인증 앱 등록 창구가 여기amr IdP 가 「이렇게 확인했습니다」고 적어 보내는 메모. 그림 2 의 B 에서 Access 가 읽는 것세션 한 번 확인받고 다시 안 묻는 시간. 활동으로 연장되지 않습니다WARP · Cloudflare One Client 회사 PC 에 설치하는 접속 프로그램. 설치하면 Cloudflare 가 「그 기기」를 알게 됩니다(그림 7). 앱에서 「Authenticate with Cloudflare One Client」를 켜 두면 그때만 이 세션이 나머지를 이깁니다
읽는 순서: 그림 1 → 2 → 3 을 본 뒤 아래 「직접 해보기」 카드에서 실제로 눌러 봅니다. 4 는 관리자, 5·6 은 운영할 때, 7 은 「접속 프로그램을 설치해야 하나」를 정할 때.
내 계정으로 직접 — 만들고, 들어가고, 지워 봅니다
진짜 Cloudflare Zero Trust · 흉내 아님
아래 번호 다섯 개를 순서대로 누르시면 됩니다. 중간의 M 은 순서와 별개로, 로그인만 되면 언제든 해 볼 수 있는 2차 인증 등록입니다.
3·4·5번에서 실제로 지워집니다 — 그리고 무엇이 안 지워지는지가 이 실습의 요지입니다.
운영 매뉴얼 M5·M6 이 글로 설명하는 것을 여기서는 몸으로 겪습니다.
🔵 여기서 쓰는 것은 「여러분 이메일 하나」입니다. Cloudflare 계정을 만들 필요도, 회사 IdP
계정을 받을 필요도 없습니다 — 개인 지메일도 됩니다(주소에 +lab 처럼
붙여도 같은 메일함으로 코드가 옵니다). 홈에서 그 주소로 신청하면 이 실습 계정의 Zero Trust
사용자가 되고, 아래 ①~⑤ 는 전부 그 신원으로 돕니다 — 비밀번호는 만들지 않습니다.
그래서 화면에서 여러분을 찾을 자리는 ③ 좌석과 ④ 기기입니다. 로그인하면 거기 여러분
줄이 생기고, 그 줄이 생기고 사라지는 것을 보는 것이 이 실습입니다.
① 표는 회사 AD 를 흉내 낸 랩 IdP 목록이라 여기에 없는 것이 정상입니다
🔴 「④ 기기」에 줄이 생기려면 Cloudflare One Client 를 설치해야 합니다 — 예외가 없습니다.
기기 등록은 클라이언트가 조직에 붙을 때 만들어지는 것이라서입니다(데스크톱 Cloudflare One
Client, 옛 이름 WARP · 휴대폰 Cloudflare One Agent).
· 브라우저로 앱에 들어가는 것은 클라이언트 없이 됩니다 — 대신 기기 등록이 생기지 않습니다
(그림 7: 그때 Cloudflare 가 아는 것은 「그 요청」 하나입니다).
· 좌석은 기기가 아니라 신원에 붙습니다 — 클라이언트 없이 로그인만 해도 좌석은 잡힙니다(M7).
· 트래픽을 안 보내고 기기 상태만 주는 모드(Posture only)도 클라이언트는 설치합니다.
🔵 이 랩에서 클라이언트로 붙어 보는 것도 됩니다 — 팀 이름은
mzccfteam 입니다(warp-cli teams-enroll mzccfteam,
또는 앱의 Team name 칸). 붙으면 「④ 기기」에 여러분 단말 줄이 생기고, M3·M4 의 함정
(지울수록 목록이 길어지는 것)을 본인 단말로 볼 수 있습니다.
🔴 다만 등록에는 등록 관문(Device enrollment permissions)이 따로 있고, 이 랩의 사람용
정책은 회사 도메인 주소만 허용하도록 되어 있습니다 — 개인 주소로는 거부될 수 있습니다
(그때 화면에 Enrollment request is invalid 로 끝납니다). 로그인·좌석·2차 인증
실습은 개인 주소로 전부 되고, 기기 등록만 이 제한을 받습니다.
왜 「지웁니다」가 넷으로 갈라지나
Cloudflare 에는 「사용자」라는 단일 객체가 없습니다.
Zero Trust 의 전제가 「신원 저장소를 우리가 갖지 않습니다」이기 때문입니다 —
회사 신원은 회사 IdP(AD FS)에 있고 Cloudflare 는 판정만 합니다.
그래서 Cloudflare 가 가진 것은 사람이 아니라 아래 셋뿐이고, 넷은 서로를
이메일 문자열로만 가리킵니다. 참조가 아니라 이름이 우연히 같을 뿐입니다.
1IdP 가입자
계정과 비밀번호. IdP 서버에 있고 Cloudflare 는 이것을 모릅니다.
로그인할 때 IdP 가 서명해 보내는 「이 사람은 …입니다」를 믿을 뿐입니다.
2로그인 허용
조건식입니다. email == "a@b.c" 는 그 사람을 가리키는 게 아니라
문자열 비교입니다. 그래서 사람을 지워도 조건은 그대로 남습니다.
3좌석
과금 단위입니다. 「이 계정에서 몇 명이 쓰나」.
권한이 아니라 점유 상태라서, 반납해도 ②가 허용하면 다시 점유합니다.
4단말 등록
WARP 가 만든 기기별 키입니다. 사람이 아니라 기기에 붙고,
한 사람이 여러 개를 가집니다. 지우면 재인증 때 새 키·새 줄이 생깁니다.
그래서 「사용자 삭제」라는 동작이 성립하지 않습니다 — 지울 사용자 객체가 없기 때문입니다.
실측으로 확인할 수 있습니다: 사용자 레코드에는
email · last_successful_login · access_seat · seat_uid 뿐이고
비밀번호도 권한도 소속 정책도 없습니다.
정책의 include 도 사용자 ID 가 아니라 이메일 문자열입니다.
이것이 M5·M6 의 근본 이유이고, 아래 표가 넷을 한 줄에 놓는 이유입니다 —
어긋난 자리가 곧 답입니다.
계정은 두 군데서 만듭니다 — 여기가 제일 헷갈리는 지점입니다. ①사내 IdP 가입 →
AD FS 를 흉내 낸 SAML IdP 입니다. 이메일 + 비밀번호 + 6자리를 실제로 검증합니다.
사내 Active Directory 계정에 해당한다고 보시면 됩니다. ② 아래 실습 계정 신청 — 우리 명단이고, 여기 있으면 Cloudflare Access 가 들여보냅니다(승인 없음). 둘 다 있어야 끝까지 갑니다. 하나만 있으면 어디서 막히는지가 곧 실습입니다.
1가입 신청운영자?신청만으로는 좌석이 안 생깁니다. 좌석은 로그인에 성공한 순간 잡힙니다 (등록 화면에 로그인만 해도 잡힙니다 — 실측).
그래서 운영자가 볼 숫자는 가입 인원과 실제 좌석 수 둘이고, 이 둘은 어긋납니다 — 가입만 하고 한 번도 안 들어온 사람은 좌석이 없습니다.
🕒 1주일 로그인이 없으면 명단과 좌석을 함께 정리합니다. 승인이 없어진 뒤로 이 정리가 자리를 만드는 유일한 장치입니다 — 가입이 막히는 순간 자동으로 한 번 돕니다.
홈 화면에서 이메일을 넣습니다. 아직 아무 일도 일어나지 않습니다 —
우리 명단에 한 줄이 생길 뿐이고 Cloudflare 는 당신을 모릅니다.
2로그인
신청한 뒤 바로 누르면 이메일로 여섯 자리 코드가 옵니다.
통과하는 순간 Cloudflare Zero Trust 사용자가 생기고 좌석 하나를 차지합니다.
명단에 없는 주소로 누르면 막힙니다 — 그것도 실습입니다.
M2차 인증(MFA) 등록
인증 앱 · Windows Hello/지문 · 보안키 셋 다 켜 두었습니다.
등록은 App Launcher 안에서 합니다 — 그 화면이 어디 있는지 몰라서 못 찾는 것이
현장 1순위 질문입니다(운영 매뉴얼 M9).
3좌석을 반납해 봅니다
로그인하는 순간 Zero Trust 좌석 하나를 차지합니다. 그걸 지금 반납해 봅니다.
이 버튼이 보내는 요청은 대시보드 「Remove users」와 완전히 같습니다.
반납해도 이 화면은 그대로 열려 있고, 다시 로그인하면 좌석을 다시 차지합니다.
4내 기기 목록 · 지우기
WARP 로 등록한 단말이 여기 나옵니다. 지우면 그 단말이 다시 인증할 때
기기 줄이 새로 생깁니다 — 「지울수록 목록이 길어집니다」를 직접 보시게 됩니다.
5계정을 통째로 지웁니다
Access 허용 명단에서 빠집니다 — 다음부터는 코드조차 안 옵니다.
좌석도 함께 반납됩니다. 그런데 이 화면은 계속 열려 있습니다. 왜 그런지가 이 실습의 답입니다.
되돌릴 수 있습니다 — 「다시 신청」이 나옵니다.
① 사내 IdP 가입자 · 2차 인증 — AD 계정에 해당
여기 없으면 로그인 화면에서 비밀번호가 틀렸다고 나옵니다.
Cloudflare 는 이 목록을 모릅니다 — 별도 서버입니다.
🔴 이 표의 「2차 인증」에는 지문이 절대 안 나옵니다. 이 사내 IdP 는
인증 앱(6자리 숫자)만 지원하기 때문입니다 — 없어서가 아니라 애초에 못 하는 것입니다.
지문·보안키는 아래 ②③④ 표(Cloudflare 쪽)에서 보십시오.
실무에서도 같습니다 — IdP 가 지문을 지원하지 않으면, 지문을 쓰고 싶을 때
Cloudflare Access 가 직접 거는 2단계(Independent MFA)로 채웁니다 — 위 그림 2 의 A 가 그 방식입니다.
🔴 이 ① 표에 여러분이 없는 것이 정상입니다. 여기는 회사 AD 를 흉내 낸
랩 IdP 의 가입자 목록이라, 본인 이메일로 실습 계정을 신청하신 분은 이 목록에 없습니다.
여러분 신원은 아래 ②③④ 표(Cloudflare 쪽)에 나타납니다 — 로그인하셨다면 ③ 좌석과
④ 기기에 여러분 줄이 있습니다.
🔵 표 아래 Cloudflare 화면 링크는 「지금 로그인한 본인 계정」으로 갑니다 —
우리 값이 아니라 여러분 계정의 지금 상태가 열립니다.
경로 실측 2026-09-10
🔴 위 ① 표와 이 표의 「2차 인증」은 서로 다른 것입니다.
위는 사내 IdP(인증 앱만), 여기는 Cloudflare Access(지문·보안키·인증 앱).
지문은 여기에만 나옵니다.
둘 다 등록하면 로그인할 때 둘 다 물어봅니다.
⚠️ 🔴 이 목록은 「마지막 로그인 순간」에 찍힌 사진입니다. 칸 아래 시각이 그 시점이고,
그 뒤에 등록한 수단은 안 보입니다 — 한 번 더 로그인하면 갱신됩니다.
Cloudflare 에 실시간 목록 API 가 없어서(경로 4개 전부 404, 실측) 이 방법뿐입니다.
메뉴: ② Access controls › Policies · ③ Team & Resources › Users · ④ Team & Resources › Devices
API: GET /access/policies · GET /access/users ·
GET /devices/physical-devices?active_registrations=include 🔴 ④ 는 include 를 빼면 「등록 0개」 줄이 안 보입니다 — 화면 기본 필터도 같습니다.
한 줄에 명단(우리) · 로그인 허용(Access 정책) · 좌석(Cloudflare) 셋을 나란히 놓았습니다.
셋이 어긋나는 순간이 「지운다는 게 세 가지」의 실체입니다.
토큰은 본인 것을 넣습니다 — 이 화면은 토큰을 담지 않습니다.
ACC=<계정 ID> # Zero Trust 화면 주소의 32자리
TOK= # 권한: Access: Policies Read + Access: Users Read
# ② 허용 명단(정책) — include 에 이메일이 있어야 로그인이 된다
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/access/policies" -H "Authorization: Bearer $TOK" \
| jq -r '.result[] | select(.name|test("실습장 회원")) | .include[].email.email'
# ③ 좌석 — seat_uid 가 있으면 자리를 물고 있다
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/access/users?per_page=200" -H "Authorization: Bearer $TOK" \
| jq -r '.result[] | "\(.email)\taccess=\(.access_seat)\tseat=\(.seat_uid // "-")"'
# 2차 인증 — 전용 목록 API 가 없다. last_seen_identity 안에 들어 있다(마지막 로그인 시점 사진)
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/access/users/<user_id>/last_seen_identity" \
-H "Authorization: Bearer $TOK" | jq '{찍힌시각:(.iat|todate), 수단:[.mfa_authenticators[]?.name]}'
기기 목록 — 등록된 단말
지우면 그 단말이 재인증할 때 기기 줄이 새로 생깁니다.
「지울수록 목록이 길어집니다」를 여기서 보시게 됩니다.
메뉴: Team & Resources › Devices ·
API: GET /accounts/{acc}/devices/physical-devices?active_registrations=include 🔴 active_registrations=include 를 빼면 「등록 0개」 줄이 통째로 안 보입니다 — 정작 지워야 할 줄이 그것입니다.
🔴 page 를 붙이면 2750 not supported 로 죽습니다. 이 API 는 커서 페이징입니다.
토큰은 본인 것을 넣습니다 — 이 화면은 토큰을 담지 않습니다.
ACC=<계정 ID> # Zero Trust 화면 주소의 32자리
TOK= # 권한: Zero Trust Read
# 기기 줄 — 「등록 0개」까지 전부
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/devices/physical-devices?active_registrations=include&per_page=100" \
-H "Authorization: Bearer $TOK" | jq -r '.result[] | "\(.name)\t\(.active_registrations|length)개\t\(.id)"'
# 등록 하나 지우기 — 취소(revoke)가 아니라 삭제다(취소는 가상 IP 를 회수하지 않는다)
curl -s -X DELETE "https://api.cloudflare.com/client/v4/accounts/$ACC/devices/registrations/<등록ID>" \
-H "Authorization: Bearer $TOK" | jq .success
인증 로그 — 누가 언제 들어왔나 (최근 24시간)
🔴 장애 조치는 여기서 시작합니다. 「로그인이 안 됩니다」는 신고를 받으면
먼저 이 로그를 봅니다 — 시도 자체가 없으면 우리 문에 도달도 못 한 것이고,
거부로 남았으면 정책에서 막힌 것입니다. 원인이 여기서 갈립니다.
⚠️ 한 번의 로그인이 여러 줄로 남습니다(앱마다·리다이렉트마다) — 줄 수를 사람 수로 세지 마십시오.
ACC=<계정 ID> # Zero Trust 화면 주소의 32자리
TOK= # 권한: Access: Audit Logs Read (「Access: Read」 라는 그룹은 없습니다)
SINCE=$(date -u -d "-24 hours" +%Y-%m-%dT%H:%M:%SZ) # macOS: date -u -v-24H +%Y-%m-%dT%H:%M:%SZ
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACC/access/logs/access_requests?limit=50&since=$SINCE" \
-H "Authorization: Bearer $TOK" \
| jq -r '.result[] | "\(.created_at)\t\(.user_email)\t\(.action)\t\(if .allowed then "허용" else "거부" end)\t\(.ray_id)"'
메뉴: Insights & Logs › Logs › Access authentication logs ·
API: GET /accounts/{acc}/access/logs/access_requests?since=…&limit=… ray_id 는 Cloudflare 지원 문의 때 그대로 전달합니다.
화면을 믿지 마시고 Cloudflare 에 직접 물어보십시오.
아래를 그대로 붙여 넣으면 됩니다. 지우기 전과 후에 한 번씩 돌려 비교하는 것이 요령입니다.
여기서 지워지는 것은 셋이고, 셋이 서로 다릅니다.운영자?가입과 삭제는 비대칭입니다.
가입은 우리 명단만 바꿉니다(Cloudflare 설정 변경 0건). 반면 삭제는 명단과 함께 Cloudflare Access 정책과 좌석까지 정리합니다 — 좌석은 다음 로그인에 새로 잡힙니다.
들이는 것은 문턱 없이, 내보내는 것은 확실하게. 1주일 자동 정리도 같은 경로를 씁니다.
③은 좌석만(PATCH /access/seats), ④는 단말 등록만
(DELETE /devices/registrations/{id}), ⑤는 회원 자격 + 좌석을 함께 놓습니다.
이 워커의 토큰은 이 실습 계정의 좌석·사용자·정책·단말에만 닿고 그 밖은 403 입니다 —
그래서 공개 화면에서 눌러 보게 열어 두었습니다.
그 셋이 서로 다른 동작이라는 것이 바로 운영 매뉴얼 M5·M6 이 말하는 내용이고,
이 실습에서 직접 확인하시게 됩니다.
직접 해보기 — 진짜 OTP 를 등록하고, 진짜로 막히는지 봅니다
2026-09-09 두 트랙 실측 완주
2단계 인증(MFA)은 두 군데서 걸 수 있습니다. Access 가 직접 거는 것(Independent MFA — IdP 에 2단계가 없는 조직이 쓰는 방식)과
IdP 가 거는 것. 둘 다 이 랩에서 실제 인증 앱(Google · Microsoft Authenticator, Authy)으로 등록하고 검증합니다.
흉내가 아닙니다 — 틀린 코드는 막히고, 같은 코드를 두 번 쓰면 막히고, 등록 없이는 들어갈 수 없습니다.
휴대폰이 없으면 아래 totp.py 가 인증 앱 대신 6자리를 만듭니다 — 아무 곳에도 접속하지 않고 시계와 설정 키만 씁니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/totp.py
wget https://waf-lab.mzc.cflare.kr/lab/py/totp.py
파이썬 코드 보기 — totp.py · 83줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""휴대폰이 없을 때 인증 앱 대신 6자리를 만듭니다 (실습 ② · 운영 매뉴얼 M9).
🟢 아무 곳에도 접속하지 않습니다. 계정도 토큰도 필요 없습니다 — 시계와 설정키만 씁니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
python totp.py JBSWY3DPEHPK3PXP # 등록 화면의 「설정 키」를 그대로
python totp.py JBSWY3DPEHPK3PXP --watch # 30초마다 새 코드를 계속 찍습니다
어디서 설정키를 얻나 — 2차 인증 등록 화면(App Launcher › Account › MFA devices, 또는
`<팀이름>.cloudflareaccess.com/AddMfaDevice`)에서 QR 아래의 「Can't scan?」 같은 문구를
누르면 **영문·숫자 문자열**이 나옵니다. 그것이 설정 키(base32)입니다.
무엇을 계산하나 — RFC 6238 그대로입니다. 「지금 시각 ÷ 30초」를 8바이트로 만들고,
설정 키로 HMAC-SHA1 을 걸어 그 결과에서 6자리를 뽑습니다. 인증 앱이 하는 일과 같습니다.
🔴 **이 파일은 그 자체가 인증 수단입니다.** 설정 키를 넣고 돌리는 순간 그 계정의 6자리를
만들 수 있으므로, 설정 키를 **파일이나 채팅에 남기지 마십시오**. 실습용으로 한 번 쓰고
나면 등록을 지우고 휴대폰으로 다시 등록하시는 편이 낫습니다.
🔴 **시계가 틀리면 코드도 틀립니다.** 30초 창이라 앞뒤로 몇 십 초만 어긋나도 거절됩니다 —
Cloudflare 쪽은 시계가 20초 이상 어긋나면 인증 토큰 자체를 무효로 봅니다(M15).
공식 문서
Independent MFA https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/
RFC 6238 (TOTP) https://www.rfc-editor.org/rfc/rfc6238
"""
import argparse
import base64
import hashlib
import hmac
import struct
import sys
import time
def code(secret_b32, when=None, step=30, digits=6):
"""설정 키(base32) 로 그 시각의 코드를 만듭니다."""
raw = secret_b32.strip().replace(" ", "").replace("-", "").upper()
raw += "=" * ((8 - len(raw) % 8) % 8) # base32 는 8자 단위 — 부족한 만큼 채웁니다
try:
key = base64.b32decode(raw, casefold=True)
except Exception:
sys.exit("설정 키가 base32 가 아닙니다. 등록 화면에 보이는 문자열을 그대로 넣어 주십시오.")
counter = int((time.time() if when is None else when) // step)
mac = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
off = mac[-1] & 0x0F # 마지막 4비트가 「어디서 자를지」를 가리킵니다
num = struct.unpack(">I", mac[off:off + 4])[0] & 0x7FFFFFFF
return str(num % (10 ** digits)).zfill(digits)
def left(step=30):
return step - int(time.time()) % step
def main():
ap = argparse.ArgumentParser(description="인증 앱 대신 6자리를 만듭니다 (RFC 6238)")
ap.add_argument("secret", help="등록 화면의 설정 키(base32)")
ap.add_argument("--watch", action="store_true", help="30초마다 계속 찍습니다 (Ctrl+C 로 중단)")
ap.add_argument("--digits", type=int, default=6)
ap.add_argument("--step", type=int, default=30)
a = ap.parse_args()
if not a.watch:
print("%s (남은 시간 %d초)" % (code(a.secret, step=a.step, digits=a.digits), left(a.step)))
return
print("30초마다 새 코드를 찍습니다. Ctrl+C 로 멈춥니다.")
last = None
try:
while True:
c = code(a.secret, step=a.step, digits=a.digits)
if c != last:
print("%s (남은 시간 %d초)" % (c, left(a.step)))
last = c
time.sleep(1)
except KeyboardInterrupt:
print("\n멈췄습니다.")
if __name__ == "__main__":
main()
A
Access 가 직접 거는 MFAIndependent MFA
Access 가 회사 로그인과 무관하게 2단계를 한 번 더 묻는 방식입니다.
여기서는 개념만 다룹니다 — 위 그림 2 가 그 차이이고, 설정 위치와 잠겼을 때의 복구는
운영 매뉴얼 M9(인증 수단을 잃었을 때)와 M17(클라이언트가 없으면 아예 못 들어오게)에 있습니다. 눌러 보는 실습은 오른쪽 B 입니다.
B
IdP 가 거는 MFA랩 IdP · RFC 6238
① 실습 A ↗ 접속 → 선택 화면 없이 사내 IdP 로 → 사용자 선택
② 처음이면 QR 등록 화면(키 직접 입력도 가능) → 6자리로 등록 마감. 다음부터는 코드만
③ 통과 화면의 amr 에 ["pwd","otp","mfa"] 가 실려 옵니다 — 이것이 Access 「인증 방식」 정책의 근거
「등록 지우기」 버튼이 관리자 삭제를 흉내 냅니다(랩 전용) · 협력사 IdP(ZT 실습 B)는 「2단계 없이 진행」이 있습니다
조직 → 앱 → 정책 순으로 덮어씁니다. 이 앱은 totp·biometrics·security_key · MFA 세션 15분
IdP 정책(사내 필수 · 협력사 선택)
오리진이 보는 증거
없습니다. JWT 에 MFA 표시가 안 실립니다 — 통과 못 하면 이 화면에 못 올 뿐
amr · acr(OIDC) / AuthnContextClassRef(SAML)
잠기면
관리자가 Zero Trust › Users › 사용자 › MFA devices 에서 삭제 → 다음 접속 때 재등록
IdP 관리자가 풉니다(랩: 「등록 지우기」)
랩 IdP 상태 확인 중…
Access 앱 4개 + App Launcher · 인증 앱은 Google · Microsoft Authenticator · Authy 아무거나
대시보드에서는 어디를 눌러야 하나 — Independent MFA 설정 순서
Demo2 실제 화면 · 2026-09-09 캡처
찾기 힘든 이유가 있습니다. MFA 설정이 세 군데에 흩어져 있고(조직 · 앱 · 정책), 사용자 기기 삭제는 네 번째 화면(Users)에 있습니다.
아래 순서대로 누르면 됩니다. 아래 링크는 여러분이 로그인한 계정의 같은 화면을 엽니다 — 계정이 여러 개면 계정 목록이 먼저 떠서 고르면 되고, 하나면 바로 이동합니다.
로그인이 안 돼 있으면 로그인 뒤 같은 자리로 옵니다. 썸네일을 누르면 화면 캡처를 크게 볼 수 있습니다(캡처는 우리 데모 계정).
딥링크(내 계정) ·
「App launcher policies」에 Allow 정책 하나(예: 사내 도메인) → Login methods 는 「Accept all available identity providers」 → Save.
사용자의 MFA 등록 화면이 이 앱 안에 있어서, 꺼져 있으면 「Setup MFA」가 「관리자에게 문의」로 끝납니다(Demo2 실측).
🔴 기본값은 꺼짐입니다 — 공식 문서 “By default, the App Launcher is disabled.”
즉 아무것도 안 한 계정에는 App Launcher 앱이 아예 없습니다. 목록에 안 보이면 고장이 아니라 그 상태입니다.
🔴 이 정책은 「포털 문」만 엽니다. 공식 문서가 못박습니다 —
“These rules do not impact permissions for the applications secured behind Access.”
여기에 사내 도메인 Allow 를 걸었다고 뒤에 있는 앱들이 열리는 것이 아닙니다.
앱 권한은 앱마다의 정책이고, 이 정책은 「내가 쓸 수 있는 앱 목록 화면에 들어갈 수 있나」만 정합니다.
⚠️ 아래 API 는 공식 문서에 없습니다 — 저희가 실측으로 확인한 값입니다.
문서화되지 않은 것은 예고 없이 바뀔 수 있으니, 자동화하시려면 화면을 정본으로 두고 확인하십시오.
API: POST /access/appstype:"app_launcher" · Pulumi idp-lab-app-launcher
딥링크(내 계정) ·
MFA methods: Biometrics · Security key · Authenticator application On (PIV·FIDO2 는 인프라 앱용) → Authentication duration Edit(기본 24h) →
「Use identity provider MFA」는 IdP 가 amr 을 줄 때만 → 「Apply global MFA settings by default」는 끈 채로 Save.
켜면 이 계정 앱 25개 전부가 묻습니다. 두 번째 캡처:지속시간·전역 체크박스.
API: PUT /access/organizationsmfa_config
3앱에 겁니다 — 편집 화면 맨 아래 Authentication 블록
Access controls › Applications › 앱 이름 › (스크롤) Authentication › 오른쪽 위 MFA 탭
딥링크(내 계정 · 앱 목록) → 앱 이름 클릭 ·
세 선택지: Respect global enforcement setting(조직 값 따름) / Custom MFA settings(수단 + 지속시간, 「Requires MFA on every access」 포함) / Disable MFA.
실습 앱은 Customize · 3종 · 15분. 상단 필터 「Login methods」를 누르면 바로 내려갑니다.
같은 블록 Identity 탭이 로그인 방식(IdP · One-time PIN)과 「Apply instant authentication」 —
캡처.
API: 앱 mfa_config · Pulumi mfaConfig
4(선택) 정책으로 더 좁게 — 이 값이 앱·조직을 이깁니다
Access controls › Policies › 정책 이름 › 오른쪽 Additional settings › Override global multi-factor authentication settings (MFA)
딥링크(내 계정) ·
우선순위 정책 › 앱 › 조직. 예: 공용 계정 그룹만 「보안키 금지·인증 앱만」, 관리자 그룹만 「매 접속마다」.
켜지 않으면 앱 설정이 그대로 적용됩니다. API: 정책 mfa_config
5잠긴 사용자 풀기 — Users 상세 맨 아래
Team & Resources › Users › 사용자 클릭 › 「User identity」 탭 (스크롤 끝) MFA devices › ⋯ › Delete
딥링크(내 계정 · Users) ·
표에 MFA ID · 기기 이름 · 수단이 보입니다. 이 MFA ID 가 API 의 authenticator_id (목록 API 는 없습니다).
지우면 사용자는 다음 접속에 「Setup MFA」를 받습니다. 목록 화면 →
캡처.
API: DELETE /access/users/{user_id}/mfa_authenticators/{authenticator_id}
관리자 대시보드에는 「대신 등록」 기능이 없습니다. 사용자 본인이 위 링크에서 QR 을 찍습니다(App Launcher 안에서 합니다).
팀 이름은 Access settings 맨 위 「You can reach it at your team domain」 문장, 또는 Overview › Account details › Team domain 에서.
어느 실습이 어느 IdP 로 가나 — 그리고 계정은 어디에 있나
🔵 버튼이 셋이라 계정도 셋처럼 보이지만, 하나입니다.
한 번 가입하면 A·B·C 어디서나 같은 이메일·비밀번호로 들어가고, 2차 인증도 한 번만 등록하면 셋 다 통과합니다.
갈리는 것은 「그 IdP 가 2차 인증을 요구하는가」와 토큰에 실리는 값뿐입니다.
실습
허용한 IdP
첫 화면에서 벌어지는 일
실습 A
사내 IdP(랩) 하나 자동 이동 켬
선택 화면이 아예 안 뜹니다. 허용 IdP 가 하나이고 자동 이동을 켜면 Access 가 곧바로 그리로 넘깁니다.
실습 B
협력사 IdP(랩) + 사내 AD FS(랩) 자동 이동 끔
버튼 두 개짜리 선택 화면. 버튼 글씨는 IdP 표시명 그대로입니다 — IdP 이름을 지은 그대로 고객 눈에 보입니다.
실습 C
셋 전부 + One-time PIN 자동 이동 끔
버튼 셋 + 「or」 + 이메일 입력칸. One-time PIN 도 IdP 하나로 취급되므로 목록에 넣어야 그 칸이 생깁니다.
실습장 로그인 이 실습장에 들어올 때
One-time PIN + 사내 AD FS(랩)
버튼 두 개. 여러분이 이 화면을 보려고 지나온 길입니다.
그래서 무엇이 어디에 저장되나 — 다섯 군데이고 서로 다른 것을 담습니다.
무엇이
어디에
3개 IdP 가 공유하나
랩 IdP 계정 이메일 · 비밀번호
실습용 IdP 서버의 파일 하나 가입은 /signup
🔴 공유합니다. 계정을 찾을 때 어느 IdP 인지를 아예 보지 않습니다 — 이메일만 봅니다.
2차 인증 등록 인증 앱(TOTP) · 지문·보안키
같은 서버, 계정 하나에 붙습니다
🔴 공유합니다. A 에서 QR 을 찍으면 B·C 에서도 그 코드로 통과합니다.
One-time PIN 이메일로 오는 6자리
저장되는 계정이 없습니다
Cloudflare 가 그 자리에서 코드를 보내고 확인합니다. 가입이라는 것이 없습니다.
좌석 · 로그인 세션
Cloudflare(실습 계정)
IdP 와 무관합니다. 좌석은 기기가 아니라 신원에 붙습니다(운영 매뉴얼 M6).
실습장 회원 명단 어느 탭을 볼 수 있나
이 실습장 서버
Cloudflare Access 정책에는 이 목록이 없습니다 — 로그인할 때마다 Access 가 이쪽에 물어봅니다.
🔴 그래서 「B 로 가입하고 A 로 로그인」 같은 것은 없습니다. 가입은 한 번이고, 그 뒤로는
어느 버튼을 누르는가만 고르는 것입니다. 실제 조직에서는 IdP 마다 계정이 따로인 경우가 많은데,
이 실습장은 일부러 하나로 두었습니다 — 갈리는 것이 「앱 설정」뿐이어야 그 차이가 보이기 때문입니다.
⚠️ 2차 인증 요구는 IdP 마다 다릅니다 — 사내 IdP·사내 AD FS 는 필수,
협력사 IdP 는 선택(「2단계 없이 진행」을 고를 수 있습니다). 같은 계정인데 지나온 IdP 에 따라
토큰에 실리는 값이 달라집니다(amr·acr) — 오리진은 그 값으로
「2단계를 진짜로 거쳤나」를 압니다. ⑤ 절에 그 값을 나란히 놓아 두었습니다.
링크는 여러분이 로그인한 계정으로 갑니다(주소가 dash.cloudflare.com/?to=/:account/… 꼴입니다). 화면·문구는 2026-09-09 기준 — Cloudflare 가 UI 를 바꾸면 경로가 달라질 수 있습니다
운영 매뉴얼 — 도입한 뒤에 실제로 부딪히는 것들
공식 문서 + 우리 랩 실측(2026-09-10) · 회사·고객 값 없음
위 카드가 「어떻게 켜나」라면, 이 카드는 「켜고 나서 무엇이 문제가 되나」입니다.
스물두 가지 전부 현장 질문에서 나왔습니다. 제목을 누르면 근거와 화면 경로가 열립니다.
🔴 는 문서만 읽어서는 안 나오는 것,
해 보기 는 본인 계정에서 그대로 밟는 절차가 함께 들어 있다는 뜻입니다.
인증한 시점부터 정해진 시간이 흐르고 만료됩니다. 트래픽이 있어도 늘어나지 않습니다
(문서가 초기화 사유로 드는 것은 재인증 하나뿐입니다 —
“resets to zero whenever they re-authenticate with the IdP”).
안쪽 값이 바깥을 이깁니다:
① 단말(Cloudflare One Client) 세션 → ② 정책 세션 → ③ 애플리케이션 세션(즉시~1개월) → ④ 전역 세션(15분~1개월, 기본 24h).
앱에 「Authenticate with Cloudflare One Client」 가 켜져 있으면 ①이 나머지 셋을 모두 우선합니다 —
「전역이 24시간인데 왜 안 묻지?」 의 답이 대개 이것입니다.
🔴 「전역만 줄이면 됩니다」는 틀렸습니다. 클라이언트 인증이 꺼진 앱은
Access 가 ②정책 세션을 먼저 보고, 살아 있으면 ④전역을 아예 확인하지 않고 통과시킵니다
(공식 플로차트 Order of enforcement). 정책 세션 기본값은 앱 세션과 같은 24시간(최대 1개월)이라,
전역을 15분으로 줄여도 하루 종일 재인증이 안 일어납니다.
그래서 규정 시간에 맞추려면 넷을 함께 봅니다 —
클라이언트 인증을 켠 앱은 ①, 끈 앱은 ②·③·④.
②를 안 건드리면 ③(앱 세션)이 그대로 ②가 됩니다(정책 세션 기본값 = 앱 세션).
⚠️ 2단계(MFA) 세션은 이 넷과 별개입니다 — 「MFA 를 매번 묻게」 하려면 그 값을 따로 0 으로 둡니다. 이미 맺힌 SSH·RDP 연결은 만료돼도 끊기지 않습니다.
직접 해 보기 — 네 개의 세션 시계를 한 화면에 모읍니다 (읽기만, 1분)
읽기 전용 토큰이면 됩니다. 아무것도 바뀌지 않습니다.
값이 조직·앱·정책 세 곳에 흩어져 있어 화면으로는 한눈에 못 봅니다. 이 스크립트가 한 표로 모으고, 단말 세션이 우선인 앱을 따로 찍어 줍니다 — 「전역을 줄였는데 왜 안 묻지?」의 답이 대개 그 줄입니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/sessions.py
wget https://waf-lab.mzc.cflare.kr/lab/py/sessions.py
파이썬 코드 보기 — sessions.py · 119줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""세션 시계 네 개를 한 화면에 모읍니다 (운영 매뉴얼 M1).
🟢 읽기만 합니다. 아무것도 바꾸지 않습니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Access Read
export CF_ACCOUNT_ID=<계정 ID>
python sessions.py
왜 모아 보나 — 값이 **네 곳에 흩어져** 있어서 화면으로는 한눈에 못 봅니다.
그래서 「전역을 15분으로 줄였는데 왜 하루 종일 안 묻지?」가 생깁니다.
① 단말(Cloudflare One Client) 세션 조직 설정 · 앱에서 켜 두면 나머지 셋을 이깁니다
② 정책 세션 정책마다 · 비워 두면 앱 값을 그대로 씁니다
③ 앱(애플리케이션) 세션 앱마다
④ 전역 세션 조직 하나
🔴 **앱에서 「Authenticate with Cloudflare One Client」 를 켜 두면 ①이 이깁니다.**
이 표에서 그 앱은 「단말 세션 우선」으로 찍힙니다 — 그 앱의 ②③④ 는 그날 안 쓰일 수 있습니다.
🔴 **정책 세션이 비어 있으면 앱 값을 따릅니다.** 「②를 안 건드렸으니 ④가 듣겠지」가 아닙니다.
공식 문서
세션 관리 https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/
조직 조회 https://developers.cloudflare.com/api/resources/zero_trust/subresources/organizations/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/access/subresources/policies/methods/list/
"""
import json
import os
import sys
import urllib.error
import urllib.parse
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, params=None):
url = API + "/accounts/" + ACCOUNT + path
if params:
url += "?" + urllib.parse.urlencode(params)
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:
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 get_all(path, params=None):
out, cursor = [], ""
for _ in range(50):
q = dict(params or {})
q["per_page"] = "100"
if cursor:
q["cursor"] = cursor
j = call(path, q)
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
org = (call("/access/organizations") or {}).get("result") or {}
print("④ 전역 세션 %s" % (org.get("session_duration") or "(응답에 없음 — 기본 24h)"))
print("① 단말(WARP) 세션 %s" % (org.get("warp_auth_session_duration")
or "(응답에 없음 — 화면에서 확인하십시오)"))
print(" 자동 리다이렉트 %s" % ("예" if org.get("auto_redirect_to_identity") else "아니오"))
print(" 조직 MFA 강제 %s" % ("예" if (org.get("mfa_config") or {}).get("mfa_required_for_all_apps") else "아니오"))
apps = get_all("/access/apps")
print("\n앱 %d개 — ③앱 세션 · ②정책 세션 · 단말 세션 우선 여부" % len(apps))
print("%-34s %-12s %-14s %s" % ("앱", "앱 세션", "단말세션 우선", "정책 세션"))
print("-" * 86)
for a in apps:
warp_first = bool(a.get("allow_authenticate_via_warp"))
pol_txt = []
for p in (a.get("policies") or []):
d = p.get("session_duration")
pol_txt.append("%s=%s" % (str(p.get("name") or p.get("id"))[:14], d or "앱 값 따름"))
if not pol_txt:
# 앱 응답에 정책이 안 실리는 경우가 있어 따로 물어봅니다.
try:
for p in get_all("/access/apps/%s/policies" % a.get("id")):
d = p.get("session_duration")
pol_txt.append("%s=%s" % (str(p.get("name") or p.get("id"))[:14], d or "앱 값 따름"))
except SystemExit:
pol_txt.append("(정책을 못 읽었습니다 — 권한)")
print("%-34s %-12s %-14s %s" % (
str(a.get("name") or a.get("id"))[:34],
a.get("session_duration") or "기본 24h",
"예 (①이 이김)" if warp_first else "아니오",
" · ".join(pol_txt) or "-"))
print("\n읽는 법")
print(" · 🔴 비어 있는 값은 「여기서 설정하지 않았다」는 뜻이고, 0(즉시 만료)이 아닙니다.")
print(" 공식 문서 기준 — ④전역 24h(15분~1개월) · ③앱 24h(지연 없이~1개월) · ②정책 = 앱 값.")
print(" ①단말 세션은 문서에 기본값이 없어, 화면에서 확인하셔야 합니다.")
print(" · 「단말세션 우선 = 예」인 앱은 ①이 나머지를 이깁니다. 규정 시간을 맞추려면 ①을 줄입니다.")
print(" · 「정책 세션 = 앱 값 따름」이면 ②를 비워 둔 것입니다 — ③이 그대로 ②가 됩니다.")
print(" · 「앱 세션 = 기본 24h」는 그 앱에 값을 안 넣었다는 뜻입니다. ④전역을 따르는 것이 아니라")
print(" 앱 자신의 기본값 24h 가 적용됩니다 — 전역을 15분으로 줄여도 이 앱은 24시간입니다.")
print(" · 2단계(MFA) 지속시간은 이 넷과 **별개의 시계**입니다(앱·정책의 MFA 설정에서 봅니다).")
어디서 설정하나 — 다섯 개가 네 화면에 흩어져 있습니다
🔵 아래 기본값은 Cloudflare 가 새 계정에 주는 값입니다 —
여러분 계정의 현재 값이 아닙니다. 도입하면서 바꿔 두었을 수 있고, 앱·정책마다 다를 수도 있습니다.
반드시 여러분 계정에서 직접 확인하십시오 — 위 sessions.py 가 다섯 개를 한 표로 모아 줍니다.
딥링크는 지금 로그인돼 있는 계정의 그 화면으로 갑니다.
무슨 시계
어디서
기본값
범위
① 단말 세션 Cloudflare One Client
Access controls › Access settings › Cloudflare One Client authentication › Session duration 딥링크(내 계정) ↗ 앱에서 「Authenticate with Cloudflare One Client」를 켠 경우에만 씁니다
Access controls › Access settings › Set your global session duration 딥링크(내 계정) ↗
24시간
15분 ~ 1개월
MFA 세션 위 넷과 별개
앱에 걸면 Applications › 앱 › Configure, 정책에 걸면 Policies › 정책 › Configure 🔴 정책 값이 앱 값을 이깁니다 — 우리 랩 실측: 앱 15분 · 정책 5분이면 실제로 묻는 것은 5분
Independent MFA 를 켠 앱에만 있습니다
즉시(매번) ~
🔴 「기본값이면 API 응답에 그 값이 아예 안 나옵니다.」 우리 랩을 조회해 보면(2026-09-11)
조직의 session_duration·warp_auth_session_duration 과
정책 넷의 session_duration 이 전부 응답에 없습니다 — 「안 건드렸다」는 뜻이지 0 이라는 뜻이 아닙니다.
없는 것을 「없음」으로 읽으면 정반대로 판단하게 됩니다.
재부팅에 맞춰 끊고 싶다면 셋을 함께 씁니다 — ① 단말 세션을 근무 주기보다 짧게 ·
MFA 를 Require every login · Windows 라면 no_auth_no_internet.
🔴 셋째는 화면에 없습니다. 대시보드 설정이 아니라 MDM 배포 파일(mdm.xml)의 최상위 키이고,
클라이언트 2026.5.0 이상이어야 합니다. 기본값은 꺼짐이며,
켜도 잠금 중에 사설 대역(RFC 1918)은 열어 둡니다(도메인 컨트롤러·MDM 서버·프린터 때문입니다) —
그것까지 막으려면 no_auth_no_internet_block_rfc_1918 을 함께 켭니다.
자세한 것은 M2 에 있습니다.
1) 단말 세션을 근무 주기보다 짧게(예 8~10h) → 다음 날 첫 접속에 반드시 재로그인.
2) MFA 지속시간을 Require every login → 로그인마다 6자리.
3) Windows 전용 MDM 파라미터 no_auth_no_internet(클라이언트 2026.5.0+) →
미인증 상태면 부팅 직후부터 인터넷 차단.
🔴 3)의 함정: 「매 부팅마다 인증」 이 아니라 「미인증이면 차단」 입니다. 세션이 살아 있으면 재부팅해도 안 묻습니다 —
반드시 1)과 함께 써야 「부팅 = 로그인」 에 가까워집니다. 인증 창이 내장 브라우저 컴포넌트로 떠서 IdP 흐름을 타는지 먼저 시험해 보십시오.
문서: no_auth_no_internet화면: Team & Resources › Devices › Device settings · 딥링크(내 계정) ↗ 프로필을 열어 「Auto connect」·「Allow device to leave organization」을 봅니다.
용어가 두 층입니다. 기기(Device) = 실제 장비 한 대(목록의 한 줄).
등록(Registration) = 그 기기에서 클라이언트가 한 사용자 자격으로 인증해 만들어진 기록.
한 대를 두 사람이 쓰면 그 밑에 등록이 2개 붙고, 등록마다 고유 공개키와 가상 IP(v4·v6)가 배정됩니다.
Active = 살아있는 등록이 1개 이상. No active registrations = 기기 줄은 남았는데 등록이 0개.
🔴 화면은 이 줄을 기본으로 숨깁니다. Devices › Filters › 「Active registrations」 기본값이
With active registrations 라서입니다. API 도 같습니다 —
active_registrations 기본값이 only 이므로 정리 대상을 찾을 땐
include 를 명시해야 누락이 없습니다. 등록 전체는 status=all.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/hidden.py
wget https://waf-lab.mzc.cflare.kr/lab/py/hidden.py
파이썬 코드 보기 — hidden.py · 80줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/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」 로 바꿉니다 — 목록이 늘어납니다.")
export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Zero Trust Read 면 충분
export CF_ACCOUNT_ID=<계정 ID> # Zero Trust 화면 주소의 32자리
python hidden.py
네 줄을 냅니다 — ① 화면 기본값 ② 등록 0개인 줄까지 ③ 등록 status 기본값 ④ status=all.
①②의 차이가 화면이 숨기고 있는 기기 줄이고, ③④의 차이가 취소(revoke)만 되고 남아 있는 등록입니다
(취소는 가상 IP 를 안 돌려줍니다 — 아래 M5). 숨은 줄은 기기 id·이름·마지막 사용자·마지막 접속까지 함께 나옵니다.
안에서 무슨 일이 일어나나 — API 로 직접 할 때
# 계정 ID 와 읽기 전용 토큰 두 개만 있으면 된다
ACC=<계정 ID>; TOKEN=<읽기 전용 API 토큰>
A="Authorization: Bearer $TOKEN"
U="https://api.cloudflare.com/client/v4/accounts/$ACC/devices/physical-devices"
curl -s "$U" -H "$A" | jq '.result|length' # 화면 기본값(=only)
curl -s "$U?active_registrations=include" -H "$A" | jq '.result|length' # 빈 줄까지
두 숫자의 차이가 곧 화면이 숨기고 있는 기기 줄 수입니다. 0 이 아니면 이미 아래 M4 가 일어난 계정입니다.
화면으로 보려면 Devices › Filters › Active registrations 를
Include devices without active registrations 로 바꿉니다 — 목록이 늘어납니다.
화면: Team & Resources › Devices · 딥링크(내 계정) ↗ 목록 위 필터를 Include devices without active registrations 로 바꿔야 「등록 0개」 줄이 보입니다.
우리 랩에서 등록을 실제로 지우고 재현했습니다(2026-09-10).
등록을 지우고 그 단말이 다시 인증하면, Cloudflare 는 기존 기기 줄에 등록을 다시 붙이지 않고 기기 줄을 새로 만듭니다.
물리적으로 같은 장비인데 목록에 두 줄이 되고, 옛 줄이 「등록 0개」 로 남습니다. 그리고 화면은 그 줄을 숨깁니다.
기기 1줄·등록 1개 → 등록 삭제 → 기기 1줄·등록 0개 →
재인증(약 1분 30초, 자동) → 기기 2줄 → 다음 정리 때 또 반복.
그래서 「지웠는데 왜 안 줄지?」 로 보입니다. 정리할 때는 active_registrations=include 로 빈 줄까지 보고,
등록 조회의 seen_before·seen_after 로 last_seen 기준 선별을 합니다.
직접 해 보기 — 「한 대가 두 줄이 되는」 순간을 재현합니다 (약 3분)
시험용 단말로만 합니다. 이 절차는 실제로 등록을 지웁니다 —
사무실에서 쓰는 PC 로 하면 그 사람이 다시 로그인해야 합니다.
스크립트로 하는 것을 권합니다. 계정 ID·등록 id 를 손으로 찾을 필요가 없고,
기본이 미리보기라 --apply 를 붙이기 전에는 아무것도 지우지 않습니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/demo.py
wget https://waf-lab.mzc.cflare.kr/lab/py/demo.py
파이썬 코드 보기 — demo.py · 131줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""「한 대가 두 줄이 되는」 순간을 직접 재현합니다 (운영 매뉴얼 M4).
🔴 **이것만 실제로 지웁니다.** 시험용 단말로만 하십시오 —
사무실에서 쓰는 PC 로 하면 그 사람이 다시 로그인해야 합니다.
기본은 미리보기이고, `--apply` 를 붙여야 지웁니다.
export CF_API_TOKEN=<토큰> # 권한: Zero Trust Read + Write
export CF_ACCOUNT_ID=<계정 ID>
python demo.py you@company.com # ① 미리보기 — 무엇을 지울지만
python demo.py you@company.com --apply # ② 실제로 지우고 재인증을 기다립니다
무엇을 보나 — 등록을 지우고 그 단말이 다시 인증하면, Cloudflare 는 기존 기기 줄에
등록을 다시 붙이지 않고 **기기 줄을 새로 만듭니다.** 물리적으로 같은 장비인데
목록에 두 줄이 되고, 옛 줄이 「등록 0개」로 남아 화면에서 숨겨집니다.
그래서 「지웠는데 왜 안 줄지?」로 보입니다.
공식 문서
등록 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/registrations/methods/list/
등록 삭제 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/registrations/methods/delete/
개념 https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/device-registration/
"""
import json, os, sys, time, 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 를 환경변수로 넣어 주세요.")
args = [a for a in sys.argv[1:] if not a.startswith("--")]
APPLY = "--apply" in sys.argv
if not args:
sys.exit("누구의 단말인지 지정해야 합니다: python demo.py you@company.com\n"
"아무 등록이나 지우게 두지 않습니다 — 그것이 이 코드의 유일한 안전장치입니다.")
WANT = args[0].strip().lower()
WAIT = 180
def call(method, path, params=None):
url = API + "/accounts/" + ACCOUNT + path + ("?" + urllib.parse.urlencode(params) if params else "")
req = urllib.request.Request(url, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
def get_all(path, params=None):
out, cursor = [], ""
while True:
q = dict(params or {})
q["per_page"] = "100"
if cursor:
q["cursor"] = cursor
j = call("GET", path, q)
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 email_of(reg):
for k in ("user", "last_seen_user"):
v = (reg.get(k) or {}).get("email")
if v:
return v.lower()
return ""
# ── ① 지금 줄 수 ────────────────────────────────────────────────
before_devices = get_all("/devices/physical-devices", {"active_registrations": "include"})
# status=all 로 받되 **취소(revoke)된 줄은 표적에서 뺍니다.** 이미 죽은 줄이라
# 지워도 재인증이 일어나지 않아 M4 가 재현되지 않고, 「왜 안 늘지?」로 헛다리를 짚게 됩니다.
before_regs = [r for r in get_all("/devices/registrations", {"status": "all"})
if email_of(r) == WANT and not r.get("revoked_at")]
print("① 지금 기기 줄: %d개 (active_registrations=include 로 세었습니다)" % len(before_devices))
print(" 그중 %s 의 등록: %d건" % (WANT, len(before_regs)))
if len(before_regs) != 1:
print("\n② 표적을 하나로 좁혀야 합니다 — 지금 %d건입니다:" % len(before_regs))
for r in before_regs:
print(" %s %s" % (r.get("id"), r.get("name") or "-"))
sys.exit(0 if before_regs else 1)
target = before_regs[0]
print("\n② 표적: %s %s" % (target.get("id"), target.get("name") or "-"))
print("\n③ 할 일 — 이 등록을 지우고, 단말이 스스로 재인증하기를 기다립니다.")
print(" DELETE /devices/registrations/%s" % target.get("id"))
print(" 그다음 ①을 다시 세면 **줄 수가 1 늘어 있습니다.** 같은 장비인데 목록에 두 줄입니다.")
if not APPLY:
print("\n미리보기였습니다. 실제로 해 보려면 --apply 를 붙이십시오 — 시험용 단말로만.")
sys.exit(0)
# ── ④ 지웁니다 ──────────────────────────────────────────────────
call("DELETE", "/devices/registrations/" + str(target.get("id")))
print("\n④ 지웠습니다: %s" % target.get("id"))
# ── ⑤ 기다립니다 ────────────────────────────────────────────────
print("⑤ 단말이 알아서 다시 인증하기를 기다립니다 (우리 랩에서 약 1분 30초, 최대 %d초)" % WAIT)
print(" 그동안 그 단말에 아무것도 하지 마십시오 — 손대면 무엇 때문에 늘었는지 알 수 없습니다.")
deadline = time.time() + WAIT
old_ids = set(str(r.get("id")) for r in before_regs)
fresh = None
while time.time() < deadline:
time.sleep(10)
now = [r for r in get_all("/devices/registrations", {"status": "all"}) if email_of(r) == WANT]
new = [r for r in now if str(r.get("id")) not in old_ids]
if new:
fresh = new[0]
break
print(" … 아직입니다 (남은 %d초)" % max(0, int(deadline - time.time())))
# ── ⑥ 다시 셉니다 ───────────────────────────────────────────────
after = get_all("/devices/physical-devices", {"active_registrations": "include"})
delta = len(after) - len(before_devices)
print("\n⑥ 다시 세었습니다: %d → %d (%+d)" % (len(before_devices), len(after), delta))
if fresh:
print(" 새 등록: %s %s" % (fresh.get("id"), fresh.get("name") or "-"))
if delta > 0:
print("\n🔴 **줄이 늘었습니다.** 같은 장비인데 목록에 두 줄입니다 — 이것이 M4 입니다.")
print(" 옛 줄은 이제 「등록 0개」로 남아 있고, 그 줄은 아무 일도 하지 않습니다.")
elif fresh:
print("\n재인증은 됐는데 줄 수가 그대로입니다 — 옛 줄이 이미 정리됐거나 같은 줄에 붙은 것입니다.")
else:
print("\n아직 재인증이 안 됐습니다. 조금 뒤 다시 세어 보십시오.")
print("\n⑦ 되돌리기 — 새로 생긴 줄은 그대로 두고 **옛 「등록 0개」 줄만** 지우면 원래 개수로 돌아옵니다.")
print(" ⚠️ 새로 생긴 줄까지 지우면 그 단말이 또 재인증해서 또 늘어납니다. 그것이 이 실험의 교훈입니다.")
export CF_API_TOKEN=<토큰> # 권한: Zero Trust Read + Write
export CF_ACCOUNT_ID=<계정 ID> # Zero Trust 화면 주소의 32자리
python demo.py you@company.com # ① 미리보기 — 무엇을 지울지만
python demo.py you@company.com --apply # ② 실제로 지우고 재인증을 기다립니다
스크립트가 ①~⑤를 대신합니다: 지금 줄 수를 세고 → 표적을 고르고 →
지우고 → 재인증을 기다리고(최대 3분) → 다시 세어 늘어난 줄을 보여 줍니다. 되돌리는 방법도 마지막에 출력합니다.
안에서 무슨 일이 일어나나 — API 로 직접 할 때
지금 줄 수를 적습니다 — GET /devices/physical-devices?active_registrations=include 의 result 길이
그 단말의 등록 id 를 찾습니다 — GET /devices/registrations?status=all
지웁니다 — DELETE /devices/registrations/{id}
손대지 말고 1~2분 기다립니다. 클라이언트가 알아서 다시 인증합니다(우리 랩에서 약 1분 30초)
1번을 다시 셉니다 → 🔴 줄 수가 1 늘어 있습니다. 물리적으로 같은 장비인데 목록에 두 줄입니다
되돌리기: 새로 생긴 줄은 그대로 두고 옛 「등록 0개」 줄만 지우면 원래 개수로 돌아옵니다.
삭제 직전·직후에 Zero Trust 설정 11종을 전부 조회해 비교했습니다.
사용자·좌석(좌석 식별자까지) · Access 정책 · 그룹 · 애플리케이션 · 단말 프로파일 · 단말 설정 · 자세 검사 · Gateway 정책 — 전부 변화 없음.
정책과 그룹이 사용자·기기 객체를 가리키는 것이 아니라 이메일·도메인·IdP 그룹 같은 조건으로 쓰여 있어서입니다.
조건에 적힌 값은 그 사용자를 지워도 남습니다 → 정리는 두 갈래입니다: ① 단말·기기 정리 ② 이메일이 직접 적힌 정책·목록에서 그 줄 제거.
함께 확인된 것: 삭제한 등록은 status=all 로도 안 보입니다(완전히 사라집니다).
취소(revoke)는 다릅니다 — revoked_at 이 붙은 채 목록에 남고 가상 IP 를 회수하지 않습니다.
가상 IP 는 유한하므로 Cloudflare 도 취소보다 삭제를 권합니다.
직접 해 보기 — 지우기 전후를 통째로 비교합니다
읽기 전용 토큰이면 됩니다. 아무것도 바뀌지 않습니다.
삭제 직전과 직후에 설정을 통째로 받아 두고 비교합니다.
스크립트로 하는 것을 권합니다. 이건 diff 로
직접 하면 거의 반드시 틀립니다 — 아래 「왜 손으로 하면 틀리나」 참고.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/snapshot.py
wget https://waf-lab.mzc.cflare.kr/lab/py/snapshot.py
파이썬 코드 보기 — snapshot.py · 160줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/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)를 보십시오.")
export CF_API_TOKEN=<읽기 전용 토큰>; export CF_ACCOUNT_ID=<계정 ID>
python snapshot.py before # ① 지우기 전
# …여기서 등록을 지운다 (demo --apply 또는 devices --apply)…
python snapshot.py after before # ② 지운 뒤 + 비교
설정 11종(사용자·정책·그룹·앱·IdP·서비스 토큰·단말 프로파일·자세 검사·단말 설정·Gateway 룰·Gateway 목록)과
대조군 2종(등록·기기)을 받습니다. 대조군을 함께 받는 이유는 「아무것도 안 바뀜」이 「애초에 안 지움」일 수 있어서입니다 —
단말 쪽이 안 움직였으면 스크립트가 그렇게 말해 줍니다. 토큰 권한이 모자라 한 곳이 막혀도 나머지는 계속 받고,
막힌 것은 「비교 못 함」으로 따로 냅니다(조용히 「변화 없음」으로 둔갑하지 않게).
판정까지 해 줍니다 — 설정 11종 그대로 · 단말 2종만 바뀜 이면 🟢,
바뀌면 안 되는 것이 바뀌었으면 🔴 와 바뀐 필드 이름을 찍습니다.
🔴 왜 손으로 diff 하면 틀리나
updated_at · last_successful_login · last_seen 같은
시각 필드는 그냥 흐릅니다. 아무것도 안 건드려도 두 파일이 달라집니다 —
그대로 diff 하면 「사용자가 바뀌었습니다」가 나오고, 그러면 이 절의 결론이 통째로 뒤집혀 보입니다.
스크립트는 이 필드들을 빼고 비교하고, 뺀 목록을 화면에 함께 찍습니다(무엇을 안 셌는지 감추지 않으려고).
안에서 무슨 일이 일어나나 — API 로 직접 할 때
ACC=<계정 ID>; TOKEN=<읽기 전용 API 토큰>
A="Authorization: Bearer $TOKEN"
B="https://api.cloudflare.com/client/v4/accounts/$ACC"
for P in access/users access/policies access/groups access/apps \
devices/policies devices/posture gateway/rules; do
curl -s "$B/$P" -H "$A" | jq -S . > "before-$(echo $P | tr / -).json"
done
# 지운 뒤 같은 것을 after- 로 한 번 더 받아서 diff before-access-users.json after-access-users.json
# ⚠️ 시각 필드를 먼저 걷어내야 한다: jq -S 'walk(if type=="object" then del(.updated_at,.last_seen) else . end)'
전부 그대로입니다. 「사용자를 지웠는데 정책이 안 지워졌습니다」가 정상이라는 뜻입니다 —
정책은 객체가 아니라 이메일·도메인·그룹 조건으로 쓰여 있기 때문입니다.
취소(revoke)와의 차이도 같은 방법으로 봅니다: 취소한 뒤
GET /devices/registrations?status=all 에 revoked_at 이 붙은 줄이
그대로 남아 있고, 그 줄이 물고 있던 가상 IP 도 돌아오지 않습니다.
(python hidden.py 의 ③④ 줄이 그 개수를 바로 냅니다.)
화면: Team & Resources › Devices · 딥링크(내 계정) ↗ 등록을 지운 뒤 같은 사용자의 좌석이 그대로인지 Users 화면에서 함께 확인합니다.🔴 지우는 것만으로는 끝나지 않습니다. 공식 문서가 못박습니다 —
“Deleting or revoking a registration will not be permanent if the user can re-authenticate.”
클라이언트가 설치된 채 재인증되면 새 등록이 자동으로 생깁니다.
끝내려면 등록 정책(device enrollment policies)·IdP 계정·서비스 토큰에서 함께 빼야 합니다.
참고: 삭제는 가상 IP 를 돌려주지만 취소(revoke)는 돌려주지 않습니다.
그리고 어느 쪽도 좌석에는 영향이 없습니다 — 좌석은 기기가 아니라 신원에 붙습니다.문서: Device registration · Seat management
공식 문서 두 곳이 서로 다르게 읽혀 오래 헷갈리던 지점입니다. 감사 로그로 확정했습니다 —
서로 다른 두 동작을 각각 설명하고 있었습니다.
화면 버튼 → 감사 로그에 access_patch_seats / interface=UI 로 남고 보낸 값이
[{seat_uid, access_seat:false, gateway_seat:false}, …] 배열입니다. 즉 PATCH /access/seats 입니다.
좌석을 반납하고 그 사용자의 단말 등록을 지우되 사용자 레코드는 목록에 남습니다(직접 호출해 확인).
DELETE /access/users/{id} → 화면에 대응 버튼이 없는 API 전용 동작. 레퍼런스는 「좌석과 토큰을 회수합니다」 고 합니다.
실무 결론: 화면 정리를 스크립트로 옮기려면 쓸 것은 PATCH /access/seats 입니다.
그리고 영구 차단은 Cloudflare 만으로 안 됩니다 — 지운 사용자가 다시 로그인하면 좌석을 도로 차지합니다. IdP 계정 비활성화를 반드시 병행합니다.
직접 해 보기 — 두 동작의 차이를 한 사람으로 직접 확인합니다 (기본은 미리보기)
기본은 미리보기입니다 — --apply 를 붙일 때만 바뀌고, 한 사람씩만 합니다.
같은 사람에게 좌석 반납(PATCH seats) 과 사용자 삭제(DELETE users) 를 각각 걸어 보고 전후를 비교합니다. 열린 세션 수도 함께 세므로 어느 쪽도 세션을 끊지 않는다는 것을 눈으로 봅니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/remove.py
wget https://waf-lab.mzc.cflare.kr/lab/py/remove.py
파이썬 코드 보기 — remove.py · 180줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""화면의 「Remove users」 와 API 의 사용자 삭제는 무엇이 다른가 (운영 매뉴얼 M6).
🟢 기본은 읽기만 합니다. 바꾸는 것은 --release-seat / --delete-user 를 --apply 와 함께
줄 때뿐이고, 그때도 한 사람씩만 합니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<토큰> # 읽기만 할 때: Access Read · 바꿀 때: Access Write
export CF_ACCOUNT_ID=<계정 ID> # Zero Trust 화면 주소의 32자리
python remove.py --email someone@example.com # 지금 상태만 봅니다
python remove.py --email someone@example.com --release-seat --apply
python remove.py --email someone@example.com --delete-user --apply --yes
왜 두 동작이 헷갈리나 — 이름이 비슷한데 **바뀌는 것이 다릅니다.**
화면의 「Remove users」 → PATCH /access/seats 좌석만 놓습니다. 사용자 줄은 남습니다.
API 의 사용자 삭제 → DELETE /access/users/{id} 화면에 대응 버튼이 없는 동작입니다.
🔴 **어느 쪽도 지금 열려 있는 세션을 끊지 않습니다.** 이 스크립트는 그것을 말로만 하지 않고
`active_sessions` 를 전후로 세어 보여 줍니다 — 「지웠는데 왜 화면이 그대로 열려 있나」의 답입니다.
🔴 **영구 차단은 Cloudflare 만으로 되지 않습니다.** 명단(정책)에 이메일이 남아 있으면
다시 로그인해서 좌석을 다시 차지합니다. IdP 계정 비활성화를 함께 해야 끝납니다.
공식 문서
좌석 관리 https://developers.cloudflare.com/cloudflare-one/team-and-resources/users/seat-management/
좌석 PATCH https://developers.cloudflare.com/api/resources/zero_trust/subresources/seats/methods/edit/
사용자 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/
사용자 삭제 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/delete/
열린 세션 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/subresources/active_sessions/methods/list/
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
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(method, path, body=None, params=None):
"""한 번 호출. 실패하면 **조용히 넘기지 않고** 그 자리에서 보여 줍니다."""
url = API + "/accounts/" + ACCOUNT + path
if params:
url += "?" + urllib.parse.urlencode(params)
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
try:
j = json.loads(raw)
msg = json.dumps(j.get("errors"), ensure_ascii=False)
except ValueError:
msg = raw[:300]
sys.exit("%s %s → HTTP %d %s" % (method, path, e.code, msg))
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
j = call("GET", path, params=q)
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
def find_user(email):
want = email.strip().lower()
for u in get_all("/access/users"):
if str(u.get("email") or "").strip().lower() == want:
return u
return None
def sessions(user_id):
"""열린 세션 수. 권한이 없거나 기능이 꺼져 있으면 세지 못한 것으로 둡니다."""
try:
j = call("GET", "/access/users/%s/active_sessions" % user_id)
return len(j.get("result") or [])
except SystemExit:
return None
def show(label, u):
seat = u.get("access_seat")
gw = u.get("gateway_seat")
n = sessions(u.get("id"))
print(" %-6s 좌석(access) %-5s · 좌석(gateway) %-5s · 열린 세션 %s"
% (label, "예" if seat else "아니오", "예" if gw else "아니오",
"못 셈(권한)" if n is None else "%d개" % n))
def main():
ap = argparse.ArgumentParser(description="좌석 반납과 사용자 삭제를 구분해 보여 줍니다 (M6)")
ap.add_argument("--email", required=True, help="대상 한 사람")
ap.add_argument("--release-seat", action="store_true", help="화면의 「Remove users」 와 같은 요청(PATCH seats)")
ap.add_argument("--delete-user", action="store_true", help="화면에 없는 API 전용 동작(DELETE users)")
ap.add_argument("--apply", action="store_true", help="이것이 없으면 아무것도 바꾸지 않습니다")
ap.add_argument("--yes", action="store_true", help="--delete-user 에 함께 필요합니다")
a = ap.parse_args()
u = find_user(a.email)
if not u:
sys.exit("그 주소는 이 계정의 Zero Trust 사용자 목록에 없습니다: " + a.email)
print("사용자 %s" % u.get("email"))
print(" id %s" % u.get("id"))
print(" seat_uid %s" % (u.get("seat_uid") or "-"))
print(" 마지막 로그인 %s" % (u.get("last_successful_login") or "-"))
print("\n[전]")
show("지금", u)
if not (a.release_seat or a.delete_user):
print("\n무엇도 바꾸지 않았습니다. 바꾸려면 --release-seat 또는 --delete-user 를 --apply 와 함께 주십시오.")
print("두 동작의 차이:")
print(" --release-seat PATCH /access/seats 좌석만 놓습니다. 사용자 줄과 세션은 남습니다.")
print(" --delete-user DELETE /access/users/{id} 화면에 대응 버튼이 없는 동작입니다.")
return
if not a.apply:
print("\n미리보기입니다(--apply 없음). 보낼 요청:")
if a.release_seat:
print(" PATCH /accounts/%s/access/seats" % ACCOUNT)
print(" [{\"seat_uid\": \"%s\", \"access_seat\": false, \"gateway_seat\": false}]" % (u.get("seat_uid") or "?"))
if a.delete_user:
print(" DELETE /accounts/%s/access/users/%s" % (ACCOUNT, u.get("id")))
return
if a.delete_user and not a.yes:
sys.exit("--delete-user 는 --yes 도 필요합니다. 되돌릴 수 없는 동작입니다.")
if a.release_seat:
if not u.get("seat_uid"):
sys.exit("seat_uid 가 없습니다 — 좌석을 쓰고 있지 않아 반납할 것이 없습니다.")
call("PATCH", "/access/seats", body=[{
"seat_uid": u.get("seat_uid"), "access_seat": False, "gateway_seat": False,
}])
print("\n좌석을 반납했습니다(화면의 「Remove users」 와 같은 요청).")
if a.delete_user:
call("DELETE", "/access/users/%s" % u.get("id"))
print("\n사용자를 삭제했습니다(API 전용 동작).")
after = find_user(a.email)
print("\n[후]")
if after:
show("지금", after)
print("\n🔴 사용자 줄이 **그대로 남아 있습니다.** 좌석을 놓은 것과 사람을 지운 것은 다른 일입니다.")
else:
print(" 사용자 목록에서 사라졌습니다.")
print("🔴 열린 세션은 어느 쪽으로도 끊기지 않습니다 — 만료까지 그 브라우저는 계속 열립니다.")
print("🔴 명단(Access 정책)에 이메일이 남아 있으면 다시 로그인해 좌석을 다시 차지합니다.")
print(" 영구 차단은 IdP 계정 비활성화까지 해야 끝납니다.")
if __name__ == "__main__":
main()
계정당·사용자당 등록 단말 수를 제한하는 설정은 제품에 없습니다. 등록을 「삭제」하는 것은 관리자만 할 수 있지만, 「Allow device to leave organization」을 켜면
사용자가 클라이언트에서 Logout from Zero Trust 로 조직을 스스로 나갈 수 있습니다
(수동 등록한 단말만 — MDM 으로 밀어 넣은 단말은 불가).
자동으로 도는 것은 하나뿐입니다 — Settings › Admin controls › Remove inactive users from seats(1개월~1년).
인증 이벤트와 단말 Last seen 을 둘 다 매일 검사해 기간 내에 둘 다 없는 사용자를 좌석에서 빼면서 그 사용자의 등록을 전부 지웁니다.
🔴 활성 사용자에게 쌓인 옛 단말은 이 설정으로 안 지워집니다. 그건 M4 의 경로로 쌓인 것이라 스크립트로 골라내야 합니다.
🔴 그리고 좌석은 단말이 아니라 「신원」에 붙습니다 — 기기를 아무리 지워도 좌석 수는 그대로입니다.
등록 화면에 로그인만 해도 좌석을 차지합니다(등록이 완료되지 않아도 그렇습니다 — 랩에서 확인).
직접 해 보기 — 자동 정리가 누구를 데려가는지 미리 봅니다 (읽기만, 1분)
읽기 전용 토큰이면 됩니다. 아무것도 바뀌지 않습니다.
조직에 설정된 기간을 읽어 지금 그 기준에 걸리는 사람을 줄로 냅니다. --days 90 처럼 주면 「기간을 바꾸면 누가 걸리나」를 바꾸기 전에 볼 수 있습니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/inactive.py
wget https://waf-lab.mzc.cflare.kr/lab/py/inactive.py
파이썬 코드 보기 — inactive.py · 143줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""자동 정리가 **누구를** 데려가는지 미리 봅니다 (운영 매뉴얼 M7).
🟢 읽기만 합니다. 아무것도 바꾸지 않습니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Access Read
export CF_ACCOUNT_ID=<계정 ID>
python inactive.py # 조직에 설정된 기간으로 셉니다
python inactive.py --days 90 # 「90일로 바꾸면 누가 걸리나」를 미리 봅니다
무엇을 하나 — Zero Trust 에서 자동으로 도는 정리는 **사용자 단위 하나뿐**입니다
(Settings › Admin controls › Remove inactive users from seats · 1개월~1년).
이 스크립트는 그 설정값을 읽고, **지금 그 기준에 걸리는 사람**을 줄로 보여 줍니다.
🔴 **활성 사용자에게 쌓인 옛 단말은 이 설정으로 안 지워집니다.** 정리는 「사람」 단위라서,
매일 들어오는 사람의 오래된 기기 줄은 그대로 남습니다 — 그건 M4 의 경로로 쌓인 것이고
기기 쪽 스크립트(hidden.py · cleanup.py)로 골라내야 합니다.
🔴 **좌석에서 빼는 것이 차단은 아닙니다.** 명단에 남아 있으면 다시 로그인해 다시 차지합니다(M6).
공식 문서
좌석 관리 https://developers.cloudflare.com/cloudflare-one/team-and-resources/users/seat-management/
조직 조회 https://developers.cloudflare.com/api/resources/zero_trust/subresources/organizations/methods/list/
사용자 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/
"""
import argparse
import datetime
import json
import os
import sys
import urllib.error
import urllib.parse
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, params=None):
url = API + "/accounts/" + ACCOUNT + path
if params:
url += "?" + urllib.parse.urlencode(params)
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:
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 get_all(path):
out, cursor = [], ""
for _ in range(50):
q = {"per_page": "100"}
if cursor:
q["cursor"] = cursor
j = call(path, q)
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
def parse_when(s):
"""ISO 시각을 날짜로. 못 읽으면 None — **못 읽은 줄은 대상에 넣지 않습니다.**"""
if not s:
return None
t = str(s).replace("Z", "+00:00")
try:
return datetime.datetime.fromisoformat(t)
except ValueError:
return None
ap = argparse.ArgumentParser(description="자동 정리에 걸릴 사람을 미리 봅니다 (M7)")
ap.add_argument("--days", type=int, default=0, help="기준 일수를 직접 넣어 봅니다(조직 설정 대신)")
a = ap.parse_args()
org = (call("/access/organizations") or {}).get("result") or {}
# 조직 설정값은 **분 단위**로 옵니다(user_seat_expiration_inactive_time 은 예: "43200m").
raw = str(org.get("user_seat_expiration_inactive_time") or "")
set_days = 0
if raw.endswith("m") and raw[:-1].isdigit():
set_days = int(raw[:-1]) // (60 * 24)
elif raw.endswith("h") and raw[:-1].isdigit():
set_days = int(raw[:-1]) // 24
elif raw.isdigit():
set_days = int(raw) // (60 * 24)
days = a.days or set_days
print("조직 설정 user_seat_expiration_inactive_time : %s%s"
% (raw or "(꺼져 있습니다)", "" if not set_days else " (약 %d일)" % set_days))
if a.days:
print("이 실행에서 쓰는 기준 : %d일 (직접 주신 값)" % days)
if not days:
print("\n🔴 기준이 없습니다. 조직에 설정이 없으면 **자동 정리가 아예 돌지 않습니다** —")
print(" Settings › Admin controls › Remove inactive users from seats 에서 켭니다.")
print(" 지금 누가 걸릴지 보려면 --days 90 처럼 직접 주십시오.")
sys.exit(0)
now = datetime.datetime.now(datetime.timezone.utc)
users = get_all("/access/users")
old, unknown, seated = [], [], 0
for u in users:
if u.get("access_seat") or u.get("gateway_seat"):
seated += 1
when = parse_when(u.get("last_successful_login"))
if when is None:
unknown.append(u)
continue
idle = (now - when).days
if idle >= days:
old.append((idle, u))
old.sort(key=lambda x: -x[0])
print("\n사용자 %d명 · 좌석을 쓰는 사람 %d명" % (len(users), seated))
print("기준(%d일) 이상 로그인 없는 사람 : %d명" % (days, len(old)))
print("%-34s %-10s %s" % ("이메일", "안 들어온 날", "좌석"))
print("-" * 60)
for idle, u in old[:60]:
print("%-34s %-10s %s" % (
str(u.get("email") or "-")[:34], "%d일" % idle,
"쓰는 중" if (u.get("access_seat") or u.get("gateway_seat")) else "없음"))
if unknown:
print("\n마지막 로그인을 못 읽은 줄 %d개 — **대상에 넣지 않았습니다.**" % len(unknown))
print("한 번도 로그인하지 않은 사람일 수 있습니다(좌석도 없습니다).")
print("\n🔴 이 목록은 **사람**입니다. 활성 사용자에게 쌓인 옛 기기 줄은 여기 안 나옵니다 —")
print(" 그건 hidden.py / cleanup.py 로 봅니다(M4).")
앱 조건을 실행파일로 거는 것은 목적지 조건(호스트·IP·포트)으로는 안 됩니다.
대신 단말 자세 검사(Device posture)의 Application check 를 정책의 Require 로 겁니다.
Windows·macOS·Linux 모두 지원하고 필수 입력은 실행 파일 경로 하나(서명 지문·SHA-256 은 선택).
랩 단말에서 프로그램을 켜고 끄며 확인했습니다 — 정해진 주기마다 실제로 돌고, 끄면 다음 주기에 판정이 뒤집힙니다.
🔴 경로에는 「실제 실행 파일」 을 넣어야 합니다. 배치 파일·스크립트를 넣으면 실행 중이어도 「아니오」 로 나옵니다 —
검사가 보는 것은 실행 중 프로세스의 실제 실행 파일 경로여서, 스크립트를 돌리면 그 값이 해석기(셸)가 되기 때문입니다.
🔵 「설치돼 있나」 와 「돌고 있나」 는 다른 신호입니다. 파일이 있으면 실행 여부와 무관하게 체크섬이 계산되고, 없으면 빕니다. 나눠서 쓸 수 있습니다.
전제: 단말에 클라이언트가 설치돼 있어야 합니다.
문서: Application check직접 해 보기 — ① 내 계정에 이 함정이 있나 (1분, 읽기만) · ② 왜 그런지 눈으로 (Linux 단말 1대)
① 먼저 스크립트로 — 「내 계정에 이미 이 함정이 있나」 (1분, 읽기만)
아래 ②는 왜 그런지를 눈으로 보는 것이고, 이건 그 다음 질문에 답합니다 —
이미 만들어 둔 검사 중에 경로가 스크립트로 박힌 것이 있나. 단말을 한 대씩 볼 수 없는 것을 한 번에 봅니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/posture.py
wget https://waf-lab.mzc.cflare.kr/lab/py/posture.py
파이썬 코드 보기 — posture.py · 111줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""경로에 실행 파일이 아니라 스크립트가 박힌 자세 검사를 찾아냅니다 (운영 매뉴얼 M8).
🟢 읽기만 합니다. 아무것도 바꾸지 않습니다.
export CF_API_TOKEN=<읽기 전용 토큰>; export CF_ACCOUNT_ID=<계정 ID>
python posture.py
무엇을 찾나 — Application check 의 경로에 셸 스크립트·배치 파일을 넣으면
**실행 중이어도 「아니오」로 나옵니다.** 검사가 보는 것은 실행 중 프로세스의
실제 실행 파일 경로(리눅스면 /proc/<pid>/exe)여서, 스크립트를 돌리면 그 값이
해석기(/bin/sh)가 되어 지정한 경로와 안 맞기 때문입니다.
🔵 단말이 실제로 어떻게 판정됐는지(running · sha256)를 주는 **공개 API 는 없습니다**
(2026-09-11 OpenAPI 전수 확인). 그것은 대시보드에서 봅니다.
이 코드는 **설정 쪽**을 봅니다 — 단말을 한 대씩 열어 볼 수 없는 것을 한 번에 봅니다.
공식 문서
자세 검사 API https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/posture/methods/list/
Application check https://developers.cloudflare.com/cloudflare-one/reusable-components/posture-checks/client-checks/application-check/
"""
import json, os, sys, 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 를 환경변수로 넣어 주세요.")
SCRIPT_EXT = (".sh", ".bash", ".zsh", ".ksh", ".py", ".rb", ".pl", ".php", ".lua",
".bat", ".cmd", ".ps1", ".vbs", ".js", ".jar")
WIN_EXEC_EXT = (".exe", ".com", ".msi", ".dll", ".sys")
def get(path):
req = urllib.request.Request(API + "/accounts/" + ACCOUNT + path)
req.add_header("Authorization", "Bearer " + TOKEN)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8")).get("result") or []
def posture_uids(o, out):
"""정책 안 어디에 있든 device_posture.integration_uid 를 긁어 옵니다.
(Access 정책의 자세 검사 조건은 {"device_posture": {"integration_uid": "<검사 id>"}} 모양입니다.)"""
if isinstance(o, dict):
dp = o.get("device_posture")
if isinstance(dp, dict) and dp.get("integration_uid"):
out.add(str(dp["integration_uid"]))
for v in o.values():
posture_uids(v, out)
elif isinstance(o, list):
for v in o:
posture_uids(v, out)
rules = get("/devices/posture")
path_rules = [r for r in rules if r.get("type") in ("application", "file")]
print("자세 검사 %d개 중 경로를 보는 것 %d개 (Application check · File check)"
% (len(rules), len(path_rules)))
if not path_rules:
print("경로형 검사가 없습니다 — 아직 이 함정에 빠질 자리가 없습니다.")
sys.exit(0)
# 어느 정책이 이 검사를 물고 있는지 — 판정이 뒤집히면 막히는 것이 그것입니다.
used = {}
for path, label in (("/access/policies", "재사용 정책"), ("/access/apps", "앱")):
try:
for item in get(path):
got = set()
posture_uids(item, got)
for u in got:
used.setdefault(u, []).append("%s 「%s」" % (label, item.get("name") or item.get("id")))
except Exception as e:
print(" ⚠️ %s 를 못 읽었습니다(%s) — 사용처는 비워 둡니다" % (path, str(e)[:50]))
bad = 0
for r in path_rules:
inp = r.get("input") or {}
p = str(inp.get("path") or "")
osname = str(inp.get("operating_system") or "-")
low = p.lower()
kind = "Application check(돌고 있나)" if r.get("type") == "application" else "File check(설치돼 있나)"
if low.endswith(SCRIPT_EXT):
mark = "🔴"
why = ("스크립트 경로입니다. 실행 중이어도 running:false 로 나옵니다 — 검사가 보는 것은 "
"실행 중 프로세스의 실제 실행 파일이라 여기서는 해석기(/bin/sh 등)가 잡힙니다.")
elif osname == "windows" and low and not low.endswith(WIN_EXEC_EXT):
mark, why = "⚠️", "윈도우인데 실행 파일 확장자가 아닙니다. 바로가기·스크립트면 안 잡힙니다."
else:
mark, why = "🟢", ""
if mark != "🟢":
bad += 1
print("\n%s %s" % (mark, r.get("name") or r.get("id")))
print(" %s · OS %s" % (kind, osname))
print(" 경로 %s" % (p or "(없음)"))
where = used.get(str(r.get("id")), [])
print(" 쓰는 곳: " + (", ".join(where[:4]) if where
else "없음 — 만들어만 두고 아무 정책에도 안 걸었습니다"))
if why:
print(" → " + why)
print("")
if bad:
print("🔴 손봐야 할 검사 %d개. 경로를 진짜 실행 파일로 바꾸십시오." % bad)
print(" 「설치돼 있나」만 보고 싶은 것이라면 Application check 가 아니라 File check 를 씁니다 —")
print(" 파일이 있으면 돌든 안 돌든 sha256 이 계산됩니다. 다른 신호입니다.")
else:
print("🟢 스크립트 경로로 잘못 건 검사는 없습니다.")
export CF_API_TOKEN=<읽기 전용 토큰>; export CF_ACCOUNT_ID=<계정 ID>
python posture.py
경로를 보는 검사(Application check · File check)를 전부 훑어
🔴 스크립트 확장자(.sh .py .bat .ps1 …)와 ⚠️ 윈도우인데 실행 파일이 아닌 경로를 잡아냅니다.
그리고 그 검사를 어느 정책·앱이 물고 있는지까지 함께 냅니다 — 판정이 뒤집히면 막히는 것이 그것이라서요.
아무 정책도 안 건 검사는 「쓰는 곳: 없음」으로 표시합니다(있어도 아무 일 안 합니다).
🔵 판정 결과 자체를 주는 공개 API 는 없습니다.
단말별 running·sha256 은 화면(Devices › 그 단말)에서 봅니다 —
그래서 ②가 필요합니다.
왜 그런가 — 검사가 보는 것은 실행 중 프로세스의 실제 실행 파일 경로(/proc/<pid>/exe)입니다.
스크립트를 돌리면 그 값이 해석기(/bin/sh)라서 지정한 경로와 안 맞습니다. sha256 은 다른 신호입니다 — 파일이 있으면 돌든 안 돌든 계산되고, 없으면 null 입니다.
「설치돼 있나」와 「돌고 있나」를 나눠 쓸 수 있습니다.
화면: Reusable components › Posture checks · 딥링크(내 계정) ↗ 「Application check」를 만들고 정책의 Require 에 겁니다.
① 다른 수단이 남아 있습니다 (인증 앱을 잃었지만 보안키가 있는 등) → 사용자가 App Launcher › Account › MFA devices 에서
남은 수단으로 본인 확인 뒤 옛 기기를 직접 지우고 새로 등록합니다. 관리자 개입 없음.
② 수단이 하나뿐이었습니다 → 🔴 사용자가 스스로 할 수 있는 일이 없습니다.
기기를 지우려면 기존 수단으로 본인 확인을 해야 하는데 그 수단이 없기 때문입니다. 복구 코드·백업 코드도 없습니다.
관리자가 Users › 사용자 › MFA devices 에서 지워 줘야 다음 접속에 등록 화면이 다시 뜹니다(화면 작업 1~2분).
야간·휴일에 관리자가 없으면 그동안 접근이 막힙니다.
그래서 둘을 함께 권합니다: 사용자당 수단 2개 이상(①의 경로가 열리면 관리자가 아예 필요 없습니다) · 지울 수 있는 관리자 2명 이상.
🔵 공용 계정은 같은 QR 을 여러 휴대폰에 등록하면 됩니다(공식 문서가 안내하는 방법). 사용자당 인증기가 1개라는 뜻이지 휴대폰이 1대라는 뜻이 아닙니다.
직접 해 보기 — 한 사람의 2차 인증 수단을 보고, 필요하면 지웁니다 (기본은 읽기만)
기본은 읽기만 합니다 — 지우는 것은 --delete 를 --apply 와 함께 줄 때뿐입니다.
🔴 목록 API 가 없습니다. 이 목록은 「마지막 로그인 순간에 찍힌 사진」이라, 스크립트가 개수를 단정하지 않고 찍힌 시각을 함께 냅니다 — 그 뒤에 등록한 수단은 안 보입니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/mfa.py
wget https://waf-lab.mzc.cflare.kr/lab/py/mfa.py
파이썬 코드 보기 — mfa.py · 123줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""한 사람의 2차 인증 수단을 보고, 필요하면 지웁니다 (운영 매뉴얼 M9).
🟢 기본은 읽기만 합니다. 지우는 것은 --delete 를 --apply 와 함께 줄 때뿐입니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<토큰> # 읽기: Access Read · 지우기: Access Write
export CF_ACCOUNT_ID=<계정 ID>
python mfa.py --email someone@example.com
python mfa.py --email someone@example.com --delete <인증기 id> --apply
🔴 **목록 API 가 없습니다.** 이 목록은 `last_seen_identity` 안의 `mfa_authenticators` 이고,
그것은 「마지막 로그인 순간에 찍힌 사진」입니다. 그 뒤에 등록한 수단은 **안 보입니다** —
그래서 이 스크립트는 개수를 단정하지 않고 **찍힌 시각을 함께** 냅니다.
한 번 더 로그인하면 갱신됩니다.
🔴 **지우는 것과 등록해 주는 것은 다릅니다.** 관리자가 대신 등록해 줄 수는 없습니다 —
지우면 그 사람이 다음 접속에 「Setup MFA」 화면을 받아 본인이 다시 QR 을 찍습니다.
권하는 예방 — 사용자마다 수단 2개 이상(인증 앱 + 보안키/생체). 그러면 하나를 잃어도
본인이 App Launcher › Account 에서 직접 정리할 수 있고 관리자가 아예 필요 없습니다.
공식 문서
Independent MFA https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/
마지막 신원 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/subresources/last_seen_identity/methods/get/
사용자 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
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(method, path, params=None):
url = API + "/accounts/" + ACCOUNT + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method)
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:
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("%s %s → HTTP %d %s" % (method, path, e.code, msg))
def get_all(path):
out, cursor = [], ""
for _ in range(50):
q = {"per_page": "100"}
if cursor:
q["cursor"] = cursor
j = call("GET", path, q)
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
ap = argparse.ArgumentParser(description="한 사람의 2차 인증 수단 (M9)")
ap.add_argument("--email", required=True)
ap.add_argument("--delete", metavar="ID", help="지울 인증기 id")
ap.add_argument("--apply", action="store_true", help="이것이 없으면 지우지 않습니다")
a = ap.parse_args()
want = a.email.strip().lower()
user = None
for u in get_all("/access/users"):
if str(u.get("email") or "").strip().lower() == want:
user = u
break
if not user:
sys.exit("그 주소는 이 계정의 Zero Trust 사용자 목록에 없습니다: " + a.email)
print("사용자 %s" % user.get("email"))
print(" id %s" % user.get("id"))
print(" 마지막 로그인 %s" % (user.get("last_successful_login") or "-"))
seen = (call("GET", "/access/users/%s/last_seen_identity" % user.get("id")) or {}).get("result") or {}
auth = seen.get("mfa_authenticators") or []
print("\n찍힌 시각 %s ← 이 시점의 사진입니다" % (seen.get("iat") or user.get("last_successful_login") or "-"))
print("등록된 수단 %d개" % len(auth))
for m in auth:
print(" %-38s %-14s %s" % (
str(m.get("id") or "-")[:38], str(m.get("type") or "-")[:14], str(m.get("name") or "")[:30]))
if not auth:
print(" (이 사진에는 없습니다 — 한 번도 등록하지 않았거나, **이 로그인 뒤에 등록**했을 수 있습니다)")
if not a.delete:
print("\n지우려면 --delete <id> 를 --apply 와 함께 주십시오.")
print("지운 뒤 그 사람은 다음 접속에 「Setup MFA」 를 받고 본인이 다시 등록합니다.")
sys.exit(0)
if not a.apply:
print("\n미리보기입니다(--apply 없음). 보낼 요청:")
print(" DELETE /accounts/%s/access/users/%s/mfa_authenticators/%s" % (ACCOUNT, user.get("id"), a.delete))
sys.exit(0)
if auth and not any(str(m.get("id")) == a.delete for m in auth):
print("\n⚠️ 그 id 는 이 사진에 없습니다 — 사진이 낡았을 수 있어 그대로 보냅니다.")
call("DELETE", "/access/users/%s/mfa_authenticators/%s" % (user.get("id"), a.delete))
print("\n지웠습니다. 그 사람이 다음에 보호된 앱에 접속하면 「Setup MFA」 화면을 받습니다.")
print("직접 링크를 보내도 됩니다: https://<팀이름>.cloudflareaccess.com/AddMfaDevice")
위 M9 는 Zero Trust 사용자 이야기입니다. 관리자 계정이 Cloudflare 대시보드 자체의 2단계 인증에 잠기면
콘솔에서 풀 수 없습니다. 1순위 근거는 백업 코드이고, 없으면 Cloudflare 복구 절차를 본인이 직접 신청해야 합니다 —
로그인 2단계 화면의 「Lost all 2FA devices and backup codes?」 → Try recovery 입니다.
🔴 전에 로그인한 적 있는 기기에서 해야 하고, 승인 메일까지 3~5일 걸리며 단축되지 않습니다.
즉 야간에 잠기면 그날 안에 못 풉니다 — 그래서 아래 예방이 실질적인 대책입니다 — 파트너·동료가 대신 신청할 수 없습니다.
예방은 셋: 백업 코드를 초기화되는 환경(VDI 등) 밖에 보관 · 계정에 이메일 2단계 추가 · Super Administrator 2명 이상.
문서: 2FA · 백업 코드화면: My Profile › Access Management › Authentication · 딥링크(내 프로필) ↗ 🔴 계정 단위가 아니라 「사람」 단위 화면입니다 — 관리자 각자가 본인 것을 켜야 하고, 남이 대신 못 켭니다. 백업 코드도 여기서 받습니다.
AD 그룹을 자동으로 따라가게 — AD FS Claim Rule 에서 Token-Groups - Unqualified Names 를
groups 속성으로 보내면 정책·Rule group 에서 「SAML Group」 선택자로 그룹을 바로 지정할 수 있습니다.
로그인할 때 SAML 응답의 그룹을 읽으므로 AD 에서 그룹만 바꾸면 다음 로그인부터 반영되고 Cloudflare 쪽 목록을 고칠 필요가 없습니다.
🔴 Reusable components › Lists 의 이메일 목록은 Gateway 정책과 Access 애플리케이션 정책 양쪽에서 쓸 수 있습니다
(API 로는 email_list, 정책의 include·exclude·require 어디에나 들어갑니다).
정책 여러 개가 같은 사람 묶음을 쓰면 Lists 나 Rule group 중 하나로 모아 두면 됩니다 — 어느 쪽이든 됩니다.
⚠️ 정정(2026-09-10): 여기 「Lists 는 네트워크 정책 전용이라 앱 정책에는 못 씁니다」고 적혀 있었는데
틀린 내용이었습니다. 공식 문서가 “…when creating Gateway policies or Access policies” 라고 명시하고,
지원 타입에 User email addresses 가 있습니다. 문서·API 재확인으로 바로잡았습니다.
퇴사자 자동 해제(SCIM 2.0) 는 그것을 제공하는 IdP 에서만 됩니다. AD FS 는 SCIM 을 제공하지 않아 해당 없음 →
AD 계정 비활성화 + 스크립트 정리를 함께 운영합니다.
앱 일괄 등록 은 Terraform·Pulumi 로. 목록(앱명·호스트/IP·포트·대상 그룹)에서 코드를 만들고 적용 전 변경 내역을 먼저 보여준 뒤 반영합니다.
🔴 두 모드가 자주 거꾸로 이해됩니다.Exclude(기본값)는
「적어 둔 것만 빠지고 나머지 전부가 Gateway 로 갑니다」이고,
Include 는 「적어 둔 것만 Gateway 로 가고 나머지는 전부 지나칩니다」입니다.
기본 제외 목록이 사설 대역(10/8·172.16/12·192.168/16)이라,
기본 상태에서는 사내 대역이 터널을 안 타고 인터넷이 Gateway 를 탑니다 — 흔히 반대로 알고 있습니다.
🔴 이렇게 제외한 트래픽은 Gateway 네트워크·HTTP 로그에 남지 않습니다 — 공식 문서도 「로그에서 보고 싶은 사이트는 제외하지 말라」 고 명시합니다.
다만 DNS 질의는 제외 여부와 무관하게 Gateway 가 처리하므로 DNS 로그에는 남고 DNS 정책도 적용됩니다
— 단 Local Domain Fallback 에 넣은 도메인은 이것마저 빠집니다.
중국 구간 — 중국 안에서 쓰려면 전용 IP 를 따로 발급받습니다. Cloudflare 가 중국 통신사 파트너를 통해
할당하고, 대역폭과 출구(Exit) 지점이 함께 정해져서 나옵니다. 신청해서 받는 것이지 화면에서 켜는 값이 아닙니다.
받은 IP 는 Device profile › Global Acceleration › Tunnel protocol endpoints 에 넣습니다. 포트 규칙이 셋 다 다릅니다:
🔴 MDM 으로 배포한 단말은 화면만 고쳐서는 안 바뀝니다. 그 단말들은 MDM 파일 안의 endpoint 도 같이 고쳐 재배포해야 합니다
(override_warp_endpoint·override_api_endpoint).
「화면이냐 MDM 이냐」가 아니라 배포 방식에 따라 갈립니다 — 손으로 설치한 단말은 화면 설정이 내려가고, MDM 단말은 파일이 이깁니다.
여기가 오래 헷갈리던 지점이었습니다.
🔴 전환은 한 번에 하지 않습니다. 기존 회선이 살아 있는 동안 일부 단말에만 먼저 넣어 실제로 붙는지 확인하고,
확인된 뒤에 나머지로 넓힙니다. 기존 회선이 끊긴 뒤에 문제를 발견하면 되돌릴 곳이 없습니다.
🔵 2026-09-10 실제 전환 건에서 확인한 절차
🔴 Tunnel protocol 은 「지정 안 하면 기본값을 따릅니다」. 프로파일 목록에서 비어 보이는데 상세에는 값이 뜨는 경우가 있는데,
목록은 지정값, 상세는 적용값으로 읽힙니다. 제품 기본값이 MASQUE(HTTP/3) 로 바뀌어 있어,
지정이 없으면 클라이언트 업그레이드 때 프로토콜이 조용히 바뀔 수 있습니다. 경로가 까다로운 구간에서는 이 차이가 접속 성패를 가르기도 합니다.
🔴 「우회시켜 달라」는 요청이 Include 프로필에서는 뒤집힙니다. Include 로 운영하는 프로필은
목록에 없는 것이 이미 전부 우회입니다 — 그래서 우회가 목적이면 등록할 것이 없습니다.
같은 요청을 그대로 목록에 넣으면 반대로 그 주소만 터널을 타게 됩니다.
요청을 받으면 먼저 되묻습니다: 「터널을 타야 하나, 지나쳐야 하나」.
🔴 프로필을 고쳤는데 그 단말만 안 바뀐다면, 그 단말이 그 프로필을 안 받은 것입니다.
프로필은 목록 위에서 아래로 첫 일치이고(한 번 맞으면 아래는 보지 않습니다),
Default 는 항상 맨 아래에서 아무 조건에도 안 맞은 단말을 받습니다.
그래서 설정을 고치기 전에 그 단말이 어느 프로필을 받았는지를 확인해야 합니다.
반영에는 최대 10분이 걸립니다 — 바로 안 바뀌는 것은 고장이 아닙니다.
🔵 다른 VPN 과 같이 쓰는 경우 대역이 겹치는지 대조할 대상이 프로토콜에 따라 다릅니다.
단말에 생기는 가상 어댑터 주소가 WireGuard 는 172.16.0.2 로 모든 단말이 같고,
MASQUE 는 100.96.0.0/12 에서 단말마다 다른 주소를 받습니다(지정해서 바꿀 수도 있습니다).
「고정값 하나」로 알고 대조하면 MASQUE 단말에서 어긋납니다.
그리고 이름 조회(DNS)는 두 제품 중 한쪽만 담당해야 합니다 — 둘이 동시에 잡으면
M15 의 「다른 VPN 이 라우팅·DNS 를 두고 다툽니다」 증상이 됩니다.
직접 해 보기 — 프로필을 적용 순서대로 펼치고 분할 터널 모드까지 봅니다 (읽기만, 1분)
읽기 전용 토큰이면 됩니다. 아무것도 바뀌지 않습니다.
프로필은 위에서 아래로 첫 일치이고 Default 가 맨 아래입니다. 이 스크립트가 그 순서와 조건(match), 그리고 프로필마다 Include 인지 Exclude 인지를 함께 냅니다 — 고치기 전에 볼 것이 이 표입니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/profiles.py
wget https://waf-lab.mzc.cflare.kr/lab/py/profiles.py
파이썬 코드 보기 — profiles.py · 103줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/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 파일이 이깁니다 — 그쪽도 함께 고쳐 재배포해야 합니다.")
화면에 Firewall policies · Resolver policies 같은 항목이 있는데 하나도 안 켜져 있는 경우가 흔합니다.
고장이 아니라 「아직 안 쓰는 기능」입니다. Zero Trust 는 막는 자리가 둘인데, 그 둘이 하는 일이 다릅니다.
① Access (문) — 「이 사람이 이 앱에 들어가도 되나」. 신원을 보고 판단합니다.
지금 이 실습장에서 다룬 것이 전부 이쪽입니다.
② Gateway (길) — 「이 단말이 이 목적지로 나가도 되나」. 주로 목적지(도메인·IP·포트)를 보지만,
사용자 신원과 단말 상태도 조건으로 쓸 수 있습니다 — 「Access 는 신원, Gateway 는 목적지」로 잘라 두면
Gateway 로 할 수 있는 일을 과소평가하게 됩니다. 가르는 기준은 「앱 앞을 지키나, 나가는 길을 지키나」 입니다.
도박·악성코드 도메인 차단, 특정 SaaS 만 허용 같은 것이 여기입니다.
Gateway 는 다시 세 층입니다. DNS 정책은 도메인을 물어보는 단계에서 답을 안 주는 방식이라 가장 가볍고 빠릅니다.
네트워크 정책은 IP·포트 단위, HTTP 정책은 URL·파일 단위로 더 자세히 봅니다.
🔴 전부 꺼져 있어도 Access 는 정상으로 돕니다. 둘은 서로를 필요로 하지 않습니다 —
「사내 앱에 누가 들어오나」만 관리하는 조직은 Gateway 를 안 켜는 것이 오히려 자연스럽습니다.
🔴 켤 때 주의: DNS 정책은 「도메인」 단위라 IP 로 직접 가는 트래픽은 못 막습니다.
그건 네트워크 정책의 몫입니다.
🔴 그리고 Split Tunnels 에서 제외한 대역은 네트워크·HTTP 정책과 로그를 벗어나지만,
DNS 질의는 여전히 Gateway 가 처리하고 DNS 정책·로그가 적용됩니다(M12).
「제외했으니 아무것도 안 걸립니다」가 아닙니다.
삭제는 되돌릴 수 없습니다. 다만 쓰고 있는 단말은 재인증으로 자동 복구되므로, 실제로 정리 효과가 남는 것은 정말 안 쓰는 단말뿐입니다.
순서를 이렇게 둡니다.
①조회 전용 토큰으로 대상 목록만 뽑습니다 — 읽기 권한만 준 토큰은 삭제 호출 자체가 실패해서 사고가 원천적으로 안 납니다.
② 협의된 1대만 지워 보고 재인증까지 확인합니다. ③ 확인 후 일괄.
스크립트에 둘 안전장치: 기본을 미리보기로(옵션 없이는 아무것도 안 지우고 검토용 목록만) · 실행 시 확인 입력 ·
사용자별 최신 1대 보존 옵션(전 단말이 오래된 사용자도 마지막 1대는 남습니다) · last_seen 이 빈 등록은 건드리지 않기 ·
호출 제한 자동 재시도 · 실행 결과를 별도 파일로.
🟢 대상 목록은 CSV 로 뽑으실 수 있습니다 — --csv 대상.csv 를 붙이면
됩니다. 미리보기에서도 나옵니다(아직 아무것도 지우지 않은 상태입니다).
정리는 대개 「우리가 지운다」가 아니라 「목록을 뽑아 부서에 돌려 확인받는다」로 시작하는데,
화면 출력은 그 용도로 쓸 수 없어서 넣었습니다.
파일은 Excel 에서 바로 열립니다 — 한글이 깨지지 않도록 BOM 을 넣어 씁니다
(그냥 UTF-8 로 쓰면 엑셀이 시스템 인코딩으로 읽어 글자가 깨집니다).
🔵 그 스크립트를 아래에 통째로 실어 두었습니다 — 「파이썬 코드 보기」를 펼치면 전문이 나옵니다.
위 안전장치를 그대로 담았고 표준 라이브러리만 씁니다(설치할 것이 없습니다).
엔드포인트는 이 실습장 워커가 쓰는 것과 같은 것을 옮겼습니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/cleanup.py
wget https://waf-lab.mzc.cflare.kr/lab/py/cleanup.py
파이썬 코드 보기 — cleanup.py · 305줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""Zero Trust 정리 — 기기 등록 · 좌석 · 2차 인증(MFA) (운영 매뉴얼 M13).
**기본이 미리보기입니다.** 실제로 바꾸려면 `--apply` 를, 다섯 건을 넘기면 `--yes` 도 붙입니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<토큰> # 권한: Access Read+Write · Devices Read+Write
export CF_ACCOUNT_ID=<계정 ID> # Zero Trust 화면 주소의 32자리
python cleanup.py report # 읽기만 — 좌석·기기·중복 요약
python cleanup.py devices --stale-days 90 # 미리보기
python cleanup.py devices --stale-days 90 --apply --yes
python cleanup.py devices --keep-newest # 사람별 최신 1대만 남깁니다
python cleanup.py devices --stale-days 180 --csv 대상.csv # 검토용 목록만 뽑습니다
python cleanup.py seats --inactive-days 60
python cleanup.py seats --inactive-days 180 --csv 좌석.csv
python cleanup.py mfa --email 본인@주소 [--apply]
토큰은 인자로 받지 않습니다 — 셸 히스토리에 남기지 않기 위해서입니다.
알고 시작해야 하는 함정
1. active_registrations=include 를 빼면 「등록 0개」 줄이 통째로 안 보입니다(M3).
2. page= 는 2750 not supported 로 죽습니다. **커서 페이징**입니다.
3. 등록을 지우면 그 단말이 재인증할 때 **줄이 새로 생깁니다**(M4).
4. 좌석 반납은 차단이 아닙니다. 다시 로그인하면 다시 점유합니다(M6).
5. **MFA 목록 API 가 없습니다.** last_seen_identity 안의 mfa_authenticators 가 유일한
창구이고, 그것은 「마지막 로그인 순간에 찍힌 사진」입니다 — 그 뒤에 등록한 수단은 안 보입니다.
6. 삭제와 취소(revoke)는 다릅니다. 취소는 가상 IP 를 회수하지 않습니다(M5).
공식 문서
등록 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/registrations/methods/list/
등록 삭제 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/registrations/methods/delete/
기기 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/devices/subresources/devices/methods/list/
사용자 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/methods/list/
좌석 반납 https://developers.cloudflare.com/api/resources/zero_trust/subresources/seats/methods/edit/
마지막 신원 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/users/subresources/last_seen_identity/methods/get/
토큰 만들기 https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
"""
import argparse, csv, json, os, sys, urllib.parse, urllib.request
from datetime import datetime, timezone
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 api(method, path, body=None, params=None):
url = API + "/accounts/" + ACCOUNT + path + ("?" + urllib.parse.urlencode(params) if params else "")
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as r:
j = json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
sys.exit("HTTP %s — %s" % (e.code, e.read().decode("utf-8", "replace")[:200]))
if not j.get("success", True):
sys.exit("; ".join(x.get("message", "") for x in (j.get("errors") or [])))
return j.get("result")
def get_all(path, params=None):
"""커서 페이징. page= 를 쓰면 2750 으로 죽으므로 절대 쓰지 않습니다."""
out, cursor = [], ""
while True:
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"))
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 email_of(reg):
for k in ("user", "last_seen_user"):
v = (reg.get(k) or {}).get("email")
if v:
return v.lower()
return ""
def age_days(v):
if not v:
return None
try:
d = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
except ValueError:
return None
if not d.tzinfo:
d = d.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - d).days
def guard(items, args, what):
"""실행 조건. 미리보기가 기본이고, 여러 건을 한꺼번에 바꿀 때는 --yes 를 더 받습니다."""
if not items:
print("\n대상 없음 — 바꿀 것이 없습니다.")
return False
if not args.apply:
print("\n미리보기입니다. 실제로 %s 하려면 --apply 를 붙이십시오." % what)
return False
if len(items) > 5 and not args.yes:
print("\n대상이 %d건입니다. 다섯 건을 넘으면 --yes 를 함께 붙여야 합니다." % len(items))
return False
return True
def cmd_report(args):
users = api("GET", "/access/users", params={"per_page": "200"}) or []
regs = get_all("/devices/registrations", {"status": "all"})
devs = get_all("/devices/physical-devices", {"active_registrations": "include"})
seated = [u for u in users if u.get("access_seat")]
print("사용자 %d명 · 좌석 점유 %d명" % (len(users), len(seated)))
print("기기 줄 %d개 · 등록 %d건" % (len(devs), len(regs)))
zero = 0
for d in devs:
ar = d.get("active_registrations")
if (len(ar) if isinstance(ar, list) else (ar or 0)) == 0:
zero += 1
print("등록 0개인 기기 줄 %d개 (include 없이 조회하면 안 보이는 줄입니다)" % zero)
by = {}
for r in regs:
by.setdefault(email_of(r) or "(사람 미상)", []).append(r)
many = sorted(((k, v) for k, v in by.items() if len(v) > 1), key=lambda kv: -len(kv[1]))
if many:
print("\n등록이 여러 건인 사람 %d명 — 「지울수록 목록이 길어진다」(M4)" % len(many))
for email, rows in many[:10]:
print(" %s — %d건" % (email, len(rows)))
print("\n좌석을 물고 있는데 오래 안 들어온 사람:")
idle = [(age_days(u.get("last_successful_login")), u) for u in seated]
idle = [(a, u) for a, u in idle if a is not None and a >= 30]
for a, u in sorted(idle, reverse=True)[:10]:
print(" %s — %d일 전 로그인" % (u.get("email"), a))
if not idle:
print(" 없음(30일 기준)")
def write_csv(path, header, rows):
"""검토용 목록을 파일로 냅니다 — **미리보기에서도 씁니다.**
왜 있나 — 정리는 대개 「우리가 지운다」가 아니라 「목록을 뽑아 부서에 돌려
확인받는다」로 시작합니다. 화면 출력은 그 용도로 못 씁니다.
🔴 인코딩은 **utf-8-sig** 입니다. 그냥 utf-8 로 쓰면 Excel 이 한글을 깨뜨립니다
(엑셀은 BOM 이 없으면 시스템 인코딩으로 읽습니다). newline="" 은 줄이 한 칸씩
벌어지는 것을 막습니다 — 윈도우에서만 나는 증상입니다.
"""
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
w = csv.writer(fh)
w.writerow(header)
w.writerows(rows)
print("\n검토용 목록을 넣었습니다: %s (%d줄)" % (path, len(rows)))
def cmd_devices(args):
regs = get_all("/devices/registrations", {"status": "all"})
if args.email:
regs = [r for r in regs if email_of(r) == args.email.lower()]
rows = [((age_days(r.get("last_seen") or r.get("created")) or -1), r) for r in regs]
if args.stale_days is not None:
targets = [r for a, r in rows if a >= args.stale_days]
what = "%d일 이상 안 쓴 등록" % args.stale_days
elif args.keep_newest:
# 🔴 사람을 못 읽은 줄은 대상에서 뺍니다. 빈 문자열로 묶으면 서로 다른 사람의
# 등록이 한 덩어리가 되어 **남의 것을 지웁니다.**
by, skipped = {}, 0
for a, r in rows:
who = email_of(r)
if not who:
skipped += 1
continue
by.setdefault(who, []).append((a, r))
targets = []
for lst in by.values():
lst.sort(key=lambda x: x[0]) # 나이 오름차순 = 최신이 앞
targets.extend(r for _, r in lst[1:])
what = "사람별 최신 1건을 뺀 나머지"
if skipped:
print("사람을 못 읽은 등록 %d건은 대상에서 뺐습니다(누구 것인지 모르면 지우지 않습니다)." % skipped)
else:
sys.exit("무엇을 고를지 지정해야 합니다: --stale-days N 또는 --keep-newest")
print("등록 %d건 중 대상 %d건 — %s" % (len(regs), len(targets), what))
for r in targets:
print(" %s %s" % (email_of(r) or "(사람 미상)", r.get("name") or "-"))
print("\n⚠️ 지운 단말이 재인증하면 줄이 새로 생깁니다(M4). 목록을 줄이는 것이 목적이면")
print(" 등록 정책·IdP 계정까지 함께 봐야 합니다 — 이 코드는 등록만 지웁니다.")
if args.csv:
idle = {id(r): a for a, r in rows}
write_csv(args.csv,
["이메일", "단말 이름", "등록 id", "마지막 접속", "안 쓴 일수"],
[[email_of(r) or "", r.get("name") or "",
r.get("id") or "", r.get("last_seen") or r.get("created") or "",
idle.get(id(r), -1)] for r in targets])
if not guard(targets, args, "삭제"):
return
for r in targets:
api("DELETE", "/devices/registrations/" + str(r.get("id")))
print(" 지웠습니다: %s" % r.get("id"))
print("\n삭제 %d건" % len(targets))
def cmd_seats(args):
users = api("GET", "/access/users", params={"per_page": "200"}) or []
seated = [u for u in users if u.get("access_seat") and u.get("seat_uid")]
if args.email:
seated = [u for u in seated if str(u.get("email", "")).lower() == args.email.lower()]
if args.inactive_days is not None:
seated = [u for u in seated
if (age_days(u.get("last_successful_login")) or -1) >= args.inactive_days]
if not args.email and args.inactive_days is None:
sys.exit("무엇을 고를지 지정해야 합니다: --email 또는 --inactive-days N")
print("좌석 반납 대상 %d명" % len(seated))
for u in seated:
print(" %s seat_uid=%s" % (u.get("email"), u.get("seat_uid")))
print("\n⚠️ 반납은 차단이 아닙니다 — 다시 로그인하면 다시 점유합니다(M6).")
print(" 이 요청은 대시보드 「Remove users」 버튼이 보내는 것과 같습니다.")
if args.csv:
write_csv(args.csv,
["이메일", "seat_uid", "마지막 로그인", "안 쓴 일수"],
[[u.get("email") or "", u.get("seat_uid") or "",
u.get("last_successful_login") or "",
age_days(u.get("last_successful_login")) if u.get("last_successful_login") else -1]
for u in seated])
if not guard(seated, args, "반납"):
return
api("PATCH", "/access/seats", body=[{"seat_uid": u.get("seat_uid"),
"access_seat": False, "gateway_seat": False}
for u in seated])
print("\n반납 %d건 보냈습니다." % len(seated))
def cmd_mfa(args):
users = api("GET", "/access/users", params={"per_page": "200"}) or []
target = next((u for u in users
if str(u.get("email", "")).lower() == args.email.lower()), None)
if not target:
sys.exit("그 주소의 사용자가 없습니다: " + args.email)
uid = str(target.get("id") or "")
r = api("GET", "/access/users/" + urllib.parse.quote(uid) + "/last_seen_identity") or {}
lst = r.get("mfa_authenticators") or []
at = r.get("iat")
print("사용자 %s" % target.get("email"))
print("MFA 수단 %d개" % len(lst))
print("⚠️ 이 목록은 「마지막 로그인 순간에 찍힌 사진」입니다" +
(" 찍힌 시각 " + datetime.fromtimestamp(at, timezone.utc).isoformat() if at else ""))
print(" 그 뒤에 등록한 수단은 안 보입니다. 전용 목록 API 가 없어 이 방법뿐입니다(M9).")
for m in lst:
print(" %s type=%s id=%s" % (m.get("name") or m.get("type"), m.get("type"), m.get("id")))
print("\n⚠️ 지우면 그 사람은 다음 접속에 「Setup MFA」를 받습니다. 복구 코드는 없습니다(M9).")
if not guard(lst, args, "삭제"):
return
for m in lst:
api("DELETE", "/access/users/" + uid + "/mfa_authenticators/" + str(m.get("id")))
print(" 지웠습니다: %s" % (m.get("name") or m.get("type")))
p = argparse.ArgumentParser(description="Zero Trust 정리. 기본은 미리보기이고 --apply 로만 바뀝니다.")
sub = p.add_subparsers(dest="cmd", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--apply", action="store_true", help="실제로 바꿉니다(없으면 미리보기)")
common.add_argument("--yes", action="store_true", help="다섯 건을 넘길 때 함께 붙입니다")
# 미리보기에서도 씁니다 — 검토를 받으려면 파일이 있어야 합니다.
common.add_argument("--csv", metavar="파일", help="대상 목록을 CSV 로 냅니다(Excel 에서 바로 열립니다)")
sub.add_parser("report", help="읽기만 — 좌석·기기·중복 요약").set_defaults(fn=cmd_report)
d = sub.add_parser("devices", parents=[common], help="기기 등록 정리")
d.add_argument("--stale-days", type=int)
d.add_argument("--keep-newest", action="store_true")
d.add_argument("--email")
d.set_defaults(fn=cmd_devices)
s = sub.add_parser("seats", parents=[common], help="좌석 반납")
s.add_argument("--inactive-days", type=int)
s.add_argument("--email")
s.set_defaults(fn=cmd_seats)
m = sub.add_parser("mfa", parents=[common], help="MFA 수단 조회·삭제")
m.add_argument("--email", required=True)
m.set_defaults(fn=cmd_mfa)
a = p.parse_args()
a.fn(a)
🟢 위쪽 절들의 「직접 해 보기」도 같은 스크립트에 들어 있습니다 — 아래 셋은 읽기 전용이라
읽기 토큰만 있으면 되고 아무것도 바꾸지 않습니다.
python cleanup.py hidden # M3 — 화면이 숨기고 있는 기기 줄을 센다
python cleanup.py snapshot --out before # M5 — 지우기 전
python cleanup.py snapshot --out after --diff before # M5 — 지운 뒤 + 비교
python cleanup.py posture # M8 — 경로가 스크립트로 박힌 자세 검사 찾기
python cleanup.py demo --email 본인@주소 # M4 재현 (--apply 는 실제로 지운다)
🟢 어디까지 확인됐나 — 가짜 API 를 끼운 회귀 검사 74건이 돕니다. 특히 틀리면 남의 것을 지우는 자리를 겨냥합니다:
--apply 없이는 삭제·변경 호출이 한 건도 나가지 않고, 대상이 다섯 건을 넘으면 --yes 없이 막히며,
사람을 못 읽은 등록은 「최신 1대만 남기기」 대상에서 빠집니다(빈 값으로 묶으면 남의 등록이 섞입니다).
🔴 아직 확인 안 된 것 — 가짜 API 는 우리가 기대하는 응답 모양을 줍니다. 그래서
살아 있는 계정에 붙여 본 적이 없다는 사실은 그대로입니다: 실제 응답의 필드 이름·중첩, 100건을 넘는 커서 페이징,
호출 제한 재시도, 오류 문구. 그래서 처음 쓸 때는 report 부터 돌려 보고(읽기만 합니다),
삭제 계열은 본인 계정으로 한 건 해 본 뒤에 넓힙니다.
🔴 폐기 예정 엔드포인트에 주의 — 옛 GET /accounts/{acc}/devices 는 deprecated 입니다.
새 스크립트는 기기 /devices/physical-devices, 등록 /devices/registrations 를 씁니다.
시험 환경은 별도 무료 계정을 하나 만드는 것이 가장 부담이 없습니다(무료 플랜에도 좌석이 포함됩니다 — 정확한 수는 가입 화면에서 확인).
다만 실제 대상 선정은 결국 운영 계정에서 해야 합니다 — 무료 계정에는 그 조직의 사용자·단말·정책이 없기 때문입니다.
접속이 중간에서 더 진행되지 않는 증상은, 관리자 화면에 로그인 성공 기록이 남아 있는 채로 나타나는 일이 많습니다.
모순처럼 보이지만 당연합니다 — 신원 확인(로그인)과 터널 연결은 다른 단계여서, 앞이 성공해도 뒤에서 멈춥니다.
즉 「로그인은 되는데 연결이 안 됩니다」는 고장의 종류가 아니라 어느 단계까지 갔다는 정보입니다.
🔴 「재설치·재인증·회선 변경」을 먼저 시키면 근거가 사라집니다. 증상이 사라지면 원인도 같이 사라져서 다음에 또 겪습니다.
순서는 ① 범위를 가르고 ② 근거를 받고 ③ 그다음에 조치입니다.
① 범위 가르기 — 두 질문으로 절반이 정해집니다
· 같은 네트워크의 다른 단말도 안 되는가 → 다른 단말도 안 되면 그 네트워크, 그 단말만이면 단말·계정.
· 다른 회선(휴대폰 테더링 등)에서는 되는가 → 되면 원래 네트워크에서 필요한 경로가 막힌 것이 유력합니다(M16).
이때 회선을 바꿔 보는 것은 조치가 아니라 실험입니다. 목적은 다음에 볼 곳을 정하는 것이고
「그럼 그 회선을 쓰세요」로 끝나는 것이 아닙니다 — 사용자에게 요청할 때 이 말을 함께 적어 주면 오해가 없습니다.
② 근거 받기 — 진단 묶음 한 줄
단말에서 warp-diag 를 실행하면 진단 묶음이 만들어집니다.
Windows·macOS 는 바탕화면에 warp-debugging-info-<날짜>-<시각>.zip,
Linux 도 데스크톱이 있으면 바탕화면, 바탕화면 폴더가 없는 서버에서는 명령을 실행한 폴더에 생깁니다(2026-09-23 세 OS 실측). 안에서 볼 것은 넷입니다:
· daemon.log — 공식 문서가 「가장 유용한 디버그 로그」라고 부르는 파일. 클라이언트가 한 일이 시간순으로 다 있습니다.
· warp-status.txt 그 순간의 연결 상태 · warp-settings.txt 실제로 적용된 설정
· connectivity.txt — 이름 조회(DNS)와 HTTP 확인 결과
증상이 드문드문이라면 더 자세히 남길 수 있습니다 — warp-cli debug extra start 로 켜고 재현한 뒤
warp-cli debug extra stop. 패킷 기록·DNS 기록까지 남고, 끄는 것을 잊어도 10분 뒤 자동으로 꺼집니다.
🔵 단말에 손을 못 대는 경우(해외 지점·현장 인원)는 대시보드에서 원격으로 받습니다 — DEX › Remote captures
(메뉴에 「Client packet capture」로 보이기도 합니다). Windows·macOS·Linux, 클라이언트 v2024.12.492.0 이상,
그리고 그 단말이 인터넷에 붙어 있는 동안에만 받아집니다.
③ 로그에서 찾는 문구
All Happy Eyeballs checks failed — 이 줄이 있으면 터널을 맺을 상대에 아예 닿지 못한 것입니다.
공식 문서가 드는 원인은 셋이고, 이 순서로 확인하면 됩니다:
① 로컬·네트워크 방화벽이 필요한 주소를 막고 있습니다(M16) ·
② 다른 VPN 클라이언트가 라우팅 표와 이름 조회를 두고 다투고 있습니다(M12) ·
③ 국가·통신사 차원의 차단.
인증 단계에서 멈춘다면 시각과 제한 시간을 먼저 봅니다.
🔴 단말 시계가 20초 이상 어긋나면 인증 토큰(JWT)이 무효가 됩니다 — 화면에는 그냥 「인증 만료」로 보입니다.
그리고 브라우저에서 로그인을 마친 뒤 1분 안에 클라이언트로 돌아와야 합니다.
이 둘은 사용자 실수처럼 보이지 않아서 오래 헤맵니다.
🔵 몇 명이 겪는지, 어디에서 겪는지를 함께 적습니다. 같은 날 서로 다른 지역에서 여러 건이 올라오면
한 단말의 문제가 아니라 공통 경로·정책·클라이언트 버전을 봐야 한다는 신호입니다.
집·휴대폰 회선에서는 되는데 사무실이나 협력사 망에서만 안 된다면 방화벽·프록시를 봅니다.
허용해야 하는 주소는 계정마다 다르지 않은 고정 공개값이고 공식 문서에 그대로 있습니다 —
그래서 협력사·타사 보안팀에 그대로 전달해도 됩니다. 우리 조직 정보가 들어가지 않습니다.
🔴 도메인만 허용하면 막힙니다. 위 도메인들은 조회할 때마다 다른 IP 로 답할 수 있는데,
클라이언트는 받은 답을 무시하고 표의 IP 로 직접 붙습니다(공식 문서에 명시된 동작).
도메인 기준 정책만 넣고 「열었습니다」고 판단하는 것이 가장 흔한 실패입니다 — IP 도 함께 허용해야 합니다.
🔴 터널 안 점검은 방화벽이 아니라 우리 정책의 문제입니다. 터널 안에서 일어나므로 방화벽 목록에는 넣지 않아도 되지만,
Gateway 의 HTTP·네트워크 정책이 그것을 막으면 연결이 안 끝납니다. 막힌 자리가 사내 방화벽이 아닐 수도 있다는 뜻입니다.
🔴 연결 점검은 시스템 프록시를 경유하지 않습니다 — 문서 표현 그대로
“Requests will not use a proxy server, even if one is configured for the system.”
사내 프록시를 강제하는 환경이라면 이 주소들은 직접 통신 예외로 빼 두어야 합니다.
🔴 클라이언트 버전에 따라 도메인이 다릅니다.2026.6.0 이상은 api.devices.cloudflare.com,
그 이전은 zero-trust-client.cloudflareclient.com 과 notifications.cloudflareclient.com 을 씁니다.
버전이 섞인 조직이 대부분이니 셋 다 열어 두는 편이 안전합니다.
⚠️ UDP 를 먼저 허용합니다. 정상 경로는 MASQUE 는 UDP 443, WireGuard 는 UDP 2408 이고,
막히면 표의 폴백 포트(TCP 443 포함)로 넘어갑니다. 붙기는 하지만 그건 우회로입니다 —
「어쨌든 붙었으니 됐습니다」로 끝내면 원인이 그대로 남습니다.
⚠️ 로그인 전에162.159.192.0/24 가 보일 수 있는데, 그것은 개인용 1.1.1.1 WARP 용이고
Zero Trust 에는 필요 없습니다. 이걸 허용하려고 시간을 쓰지 않아도 됩니다.
🔵 인증 경로도 함께 열려야 합니다 — 팀 도메인 <팀이름>.cloudflareaccess.com 과
그 조직이 쓰는 IdP(신원 공급자) 주소 둘입니다.
🔴 IdP 가 사내 전용 주소라면 사외망 단말은 로그인 자체를 못 합니다 —
협력사·지점 단말을 붙일 때 여기서 막히는 일이 많습니다. 사외에서 닿는 인증 경로가 있는지 먼저 확인합니다.
🔵 Android·ChromeOS 단말이 있으면 cloudflare-dns.com 도 열어 줍니다 —
그 단말들은 분할 터널 목록의 도메인을 이 주소로 조회합니다.
🔵 방화벽이 프로그램 기준이면 Windows 는 warp-svc.exe ·
warp-updater.exe · warp-updater-armed.exe 를 허용합니다
(설치 폴더 Program Files\Cloudflare\Cloudflare WARP\).
macOS 는 Bundle ID 가 없는 실행 파일이 있어 전체 경로로 지정해야 합니다.
🔵 시각 동기화는 문서상 「선택」이지만 열어 두는 편이 낫습니다 —
시계가 20초 이상 어긋나면 인증이 실패하기 때문입니다(M15).
문서: Client with firewall ·
Common issues화면: Team & Resources › Devices › Device settings · 딥링크(내 계정) ↗ 어느 대역이 필요한지는 프로필의 Tunnel protocol 이 정합니다 — MASQUE 면 …197.0/24, WireGuard 면 …193.0/24 입니다.
됩니다. Access 정책에 Require 로 걸면 클라이언트가 없는 단말은 로그인 화면조차 통과하지 못합니다.
이름·비밀번호·2차 인증이 전부 맞아도 막힙니다 — 「누구인가」가 아니라 「어디서 오는가」를 보는 조건이라서요.
거는 값이 두 가지인데, 이 둘을 헷갈리면 뚫립니다.
선택자
무엇을 보나
개인용 WARP 로 뚫리나
WARP
클라이언트에 연결돼 있나
뚫립니다. 공식 문서가 못박습니다 — “including the consumer version”.
집에서 개인용 WARP 를 켠 사설 노트북도 통과합니다.
Gateway
우리 조직에 등록돼 우리 Gateway 를 지나고 있나
안 뚫립니다. 회사 팀 이름으로 등록된 단말만 통과합니다.
🔴 「회사 PC 만」이 목적이면 Gateway 입니다. WARP 는 「암호화된 경로로 오나」까지만 답합니다 —
남의 회사 단말도, 개인 휴대폰도 켜기만 하면 조건을 만족합니다.
공식 문서의 표현 그대로: Gateway 는 “protect company-owned assets by only allowing access to employees” 용입니다.
🔵 로그인할 때 한 번만 보는 것이 아닙니다. 이 둘은 신원 조건과 달리 요청마다 다시 평가됩니다(공식 문서 「Checked continuously」).
그래서 들어온 뒤에 클라이언트를 끄면 그 자리에서 끊깁니다 — 세션이 남아 있어도 다음 요청이 막힙니다.
어디에 거나 — Require 입니다. Include 는 「이 중 하나라도」(OR)이고 Require 는 「전부 만족」(AND)이라,
Include 에 넣으면 다른 Include 조건 하나만 맞아도 들어옵니다. 막는 것이 목적이면 Require 에 겁니다.
🔵 전제가 하나 있습니다 — WARP 검사를 켜려면 Traffic policies › Traffic settings 의
Allow Secure Web Gateway to proxy traffic 가 켜져 있어야 합니다.
우리 랩(Demo2)에서는 gateway_proxy_enabled: true 로 확인했습니다(2026-09-11 조회).
🔵 이 두 검사에는 입력값이 없습니다. 경로·버전 같은 것을 적는 칸이 없고 만들면 끝입니다 —
API 명세에서도 type 만 있고 input 변형이 없습니다(2026-09-11 전수 확인).
그래서 「만들었는데 아무 일도 안 일어난다」가 정상입니다. 정책에 걸어야 동작합니다.
우리 랩 실측(2026-09-11): 검사 두 개(Gateway·WARP)가 만들어져 있고
전제 설정도 켜져 있지만 아직 어느 정책에도 걸려 있지 않습니다 — 그래서 지금은 아무도 막지 않습니다.
python posture.py 가 이런 검사를 「쓰는 곳: 없음」으로 표시합니다(M8).
⚠️ 막기 전에 두 가지를 정하고 시작합니다.
① 클라이언트를 설치할 수 없는 사람(협력사·개인 단말·키오스크)이 그 앱을 써야 하는가 —
쓴다면 그 사람들만 다른 정책으로 갈라 두어야 합니다(Require 는 AND 라 예외가 안 생깁니다).
② 못 들어올 때 무엇이 보이나 — 그냥 거부 화면이 뜨면 사람들은 「고장」으로 읽고 헬프데스크로 옵니다.
Access 의 Custom Pages 로 「Cloudflare One Client 을 설치하세요」와 설치 안내 링크를 넣어 두는 편이 낫습니다.
직접 해 보기 — warp 만 걸린 앱을 찾아냅니다 — 개인용 WARP 도 통과하는 그 자리 (읽기만, 1분)
읽기 전용 토큰이면 됩니다. 아무것도 바뀌지 않습니다.
앱마다 정책의 require 를 모아 warp 와 gateway 를 가릅니다. warp 만 걸린 앱은 🔴 로 찍습니다 — 「회사 PC 만」이 목적이라면 그 줄이 곧 구멍입니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/require.py
wget https://waf-lab.mzc.cflare.kr/lab/py/require.py
파이썬 코드 보기 — require.py · 143줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""「클라이언트를 설치하지 않으면 못 들어오게」가 정말 그렇게 되어 있나 (운영 매뉴얼 M17).
🟢 읽기만 합니다. 아무것도 바꾸지 않습니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<읽기 전용 토큰> # 권한: Access Read
export CF_ACCOUNT_ID=<계정 ID>
python require.py
무엇을 보나 — 앱마다 정책의 **require** 에 무엇이 걸려 있는지 모아, 두 가지를 가릅니다.
warp 「Cloudflare One 클라이언트로 붙었나」 🔴 개인용 WARP 도 통과합니다
gateway 「우리 조직의 Gateway 를 지나왔나」 조직 프로필이 걸린 단말만 통과합니다
🔴 **이것이 제일 비싼 오해입니다.** 「회사 PC 만 들어오게」가 목적이라면 require 는
**gateway** 여야 합니다. warp 만 걸어 두면 집에서 개인용 WARP 를 켠 사설 노트북도
통과합니다 — 공식 문서가 그렇게 적고 있습니다(including the consumer version).
같이 봐야 하는 것 — 기기 자세 검사(device_posture)를 require 에 걸어 두면 「회사가 관리하는
단말」에 훨씬 가까워집니다. 이 스크립트는 그 항목도 함께 세어 보여 줍니다.
공식 문서
Require 규칙 https://developers.cloudflare.com/cloudflare-one/access-controls/policies/
WARP 확인 https://developers.cloudflare.com/cloudflare-one/access-controls/policies/#require-cloudflare-one-client
Gateway 확인 https://developers.cloudflare.com/cloudflare-one/access-controls/policies/#require-gateway
정책 목록 https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/policies/methods/list/
"""
import json
import os
import sys
import urllib.error
import urllib.parse
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, params=None, quiet=False):
url = API + "/accounts/" + ACCOUNT + path
if params:
url += "?" + urllib.parse.urlencode(params)
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 get_all(path):
out, cursor = [], ""
for _ in range(50):
q = {"per_page": "100"}
if cursor:
q["cursor"] = cursor
j = call(path, q)
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
def kinds(rules):
"""require/include 규칙 목록에서 종류만 뽑습니다(중첩된 any/all 도 펼칩니다)."""
found = []
stack = list(rules or [])
while stack:
r = stack.pop()
if not isinstance(r, dict):
continue
for k, v in r.items():
if k in ("any_valid_service_token", "certificate"):
found.append(k)
elif isinstance(v, dict):
found.append(k)
stack.extend([v])
elif isinstance(v, list):
stack.extend([x for x in v if isinstance(x, dict)])
else:
found.append(k)
return found
apps = get_all("/access/apps")
rows, warp_only, gateway_apps, no_require = [], [], [], []
for a in apps:
pols = a.get("policies") or []
if not pols:
pols = call("/access/apps/%s/policies" % a.get("id"), quiet=True)
pols = (pols or {}).get("result") or []
req = []
for p in pols:
req.extend(kinds(p.get("require")))
name = str(a.get("name") or a.get("id"))[:32]
has_warp = "warp" in req
has_gw = "gateway" in req
has_posture = "device_posture" in req
rows.append((name, a.get("type") or "-", has_warp, has_gw, has_posture, len(pols)))
if has_warp and not has_gw:
warp_only.append(name)
if has_gw:
gateway_apps.append(name)
if not (has_warp or has_gw or has_posture):
no_require.append(name)
print("앱 %d개 — require 에 무엇이 걸려 있나\n" % len(rows))
print("%-32s %-14s %-8s %-9s %-9s %s" % ("앱", "종류", "warp", "gateway", "자세검사", "정책 수"))
print("-" * 92)
for name, typ, w, g, po, n in rows:
print("%-32s %-14s %-8s %-9s %-9s %d" % (
name, str(typ)[:14], "예" if w else "-", "예" if g else "-", "예" if po else "-", n))
print("\n판정")
if warp_only:
print("🔴 warp 만 걸린 앱 %d개 — **개인용 WARP 로도 통과합니다**: %s"
% (len(warp_only), ", ".join(warp_only[:8])))
print(" 「회사 PC 만」이 목적이라면 require 를 gateway 로 바꾸거나 함께 거십시오.")
else:
print("🟢 warp 만 걸린 앱은 없습니다.")
if gateway_apps:
print("🟢 gateway 가 걸린 앱 %d개: %s" % (len(gateway_apps), ", ".join(gateway_apps[:8])))
if no_require:
print("⚠️ 단말 조건이 아예 없는 앱 %d개 — 신원만 맞으면 어떤 기기로도 들어옵니다: %s"
% (len(no_require), ", ".join(no_require[:8])))
print("\n자세 검사(device_posture)를 함께 걸면 「회사가 관리하는 단말」에 더 가까워집니다.")
Cloudflare One 과 WARP 는 경쟁하는 두 제품이 아닙니다 — 층이 다릅니다.Cloudflare One 은 제품군 전체를 부르는 우산 이름입니다(Access·Gateway·DLP·Browser Isolation·Tunnel…).
WARP 는 그중 단말에 까는 프로그램 하나이고, 노트북·휴대폰의 트래픽을 Cloudflare 로 끌어와
위 정책들이 걸리게 해 주는 진입로 역할입니다.
🔵 진입로가 WARP 뿐인 것도 아닙니다. 서버 쪽에서 거는 Tunnel(cloudflared),
회선으로 붙이는 GRE·IPsec, 브라우저만 거는 PAC 파일도 같은 자리에 들어옵니다.
그래서 「Cloudflare One 을 도입한다」가 「WARP 를 전 직원 PC 에 깐다」와 같은 말은 아닙니다.
그리고 지금은 이름이 One 쪽으로 합쳐지는 중입니다. 공식 문서가 “Cloudflare One Client (formerly WARP)” 로 적습니다.
어디
예전 이름
지금 이름
데스크톱 (Windows·macOS·Linux)
Cloudflare WARP client
Cloudflare One Client
모바일 (iOS·Android)
1.1.1.1: Faster Internet · 1.1.1.1 + WARP
Cloudflare One Agent
제품군·문서
Cloudflare for Teams
Cloudflare One(대시보드 메뉴 이름은 아직 Zero Trust)
🔴 그래서 「WARP 가 없어지나요」의 답은 「아니요, 간판만 바뀌었습니다」입니다.
기능·설정·요금은 그대로이고, 실제로 물러난 것은 모바일의 옛 1.1.1.1 앱 하나입니다 —
조직에 등록해서 쓰려면 이제 Cloudflare One Agent 를 받습니다.
🔴 바꾸면 안 되는 것 — 속은 전부 아직 warp 입니다.
안내 문서에서 화면에 보이는 이름은 새 이름으로 고치되, 아래 값은 절대 치환하지 마십시오.
한 번에 찾아 바꾸면 그 자리에서 설치·배포가 깨집니다.
자리
값 — 그대로 둡니다
리눅스 패키지
apt install cloudflare-warp
macOS 설치 경로
/Applications/Cloudflare WARP.app
MDM 배포 파라미터
service_mode: warp
진단 로그 파일
warp-debugging-info-<날짜>-<시각>.zip
Access 정책 선택자
WARP · Gateway(M17 의 그 두 개)
🔵 이 이름 차이가 실제로 사고가 되는 자리는 둘입니다.
① 사용자에게 「앱스토어에서 1.1.1.1 을 받으세요」라고 안내하면 지금은 틀린 안내입니다 —
조직 등록(팀 이름 입력)은 One Agent 에서 합니다.
② 옛 1.1.1.1 앱을 쓰던 단말은 이전 안내를 따라 옮깁니다 — 그냥 지우면 등록이 끊깁니다.
막히는 지점은 하나입니다 — 인증 앱(TOTP)은 한 사람에게 하나만 등록됩니다.
공식 문서가 그렇게 못박습니다(“You can only have one TOTP authenticator enrolled at a time”).
그래서 한 계정을 여러 사람이 돌려 쓰는 자리(관제석·상황실·야간 당직 계정 등)에서는
「담당자마다 자기 휴대폰으로 6자리를 받게」 하는 것이 그대로는 되지 않습니다.
길은 셋이고, 사람이 바뀌는 빈도로 고르시면 됩니다.
방법
어떻게
회수(한 사람만 빼기)
어울리는 곳
① 같은 QR 을 여러 휴대폰에
등록 화면의 QR 하나를 담당자 전원이 각자 앱으로 찍습니다
🔴 개별 회수가 안 됩니다 — 수단을 지우고 전원이 다시 찍습니다
인원이 거의 안 바뀌는 계정
② 담당자마다 보안키·생체
보안키는 여러 개 등록됩니다. 사람마다 자기 키를 그 계정에 등록합니다
🟢 그 키만 지우면 됩니다
인원이 바뀌는 계정 — 권하는 쪽입니다
③ 그 앱만 따로 정하기
애플리케이션 설정에서 2차 인증을 앱 단위로 조정합니다
—
공용 계정만 쓰는 앱이 따로 있을 때
🔴 ①을 고르실 때 — QR 을 사진으로 돌리지 마십시오. 그 이미지(또는 setup key 문자열)는
열쇠 자체입니다. 메신저·메일로 한 번 지나가면 그 대화방에 남은 모든 사람이 6자리를
만들 수 있게 됩니다. 등록하는 자리에 모여 각자 찍는 편이 낫고, 그러지 못하면 등록 직후
이미지를 지우셔야 합니다.
같은 씨앗값을 받은 휴대폰들은 같은 시각에 같은 6자리를 보여 줍니다 —
그것이 정상이고, 왜 그런지는
「② 직접 해보기」 카드에 있는
totp.py 로 직접 만들어 보실 수 있습니다.
🔴 ③은 「공용 계정만 예외」가 되지 않습니다. 2차 인증을 켜고 끄는 단위는
사람이 아니라 애플리케이션입니다. 같은 앱을 개인 계정도 함께 쓰면, 그 앱에 준 예외는
그 앱을 쓰는 전원에게 적용됩니다. 그래서 ③은 「공용 계정 전용 앱이 따로 있을 때」만
쓸 수 있는 길입니다.
🔴 이메일로 오는 One-time PIN 은 2차 인증의 대체가 아닙니다.
그것은 「누구냐」를 확인하는 로그인 수단이고, 2차 인증은 「로그인한 뒤 한 번 더」 묻는 것입니다.
공용 메일함으로 PIN 을 받으면 그 메일함을 여는 사람 전원이 곧 로그인할 수 있으므로,
「한 번 더」에 해당하는 것이 남지 않습니다.
💬 저희 의견 — 가능하면 공용 계정 자체를 줄이시는 편이 낫습니다.
개인 계정으로 나누고 권한을 그룹으로 주면, 2차 인증도 회수도 사람 단위로 깔끔해지고
「누가 했는지」가 로그에 남습니다(공용 계정은 이것이 원천적으로 안 됩니다).
교대 근무 때문에 계정을 합쳐 두신 경우라면, 계정을 나누고 같은 그룹에 넣는 것으로
대개 같은 결과가 나옵니다. 당장 바꾸기 어려운 계정은 위 ②로 운영하시고,
그 계정 목록을 따로 관리하시는 것을 권합니다.
문서: Independent MFA화면: 애플리케이션 › Configure › Authentication · 딥링크(내 계정) ↗ 그 계정에 지금 무엇이 등록돼 있는지는 의 mfa.py 로 확인하실 수 있습니다.
먼저 확인할 것은 「초대하는 쪽」입니다. 관리자를 초대할 수 있는 사람은
Super Administrator 이고, 공식 문서는 조건을 하나 더 답니다 —
그 사람의 메일 주소가 확인(verified)된 상태여야 합니다
(“you must have a role of Super Administrator and have a verified email address”).
🔴 「초대 단추가 안 먹습니다」의 흔한 원인이 이것입니다 — 권한은 맞는데 본인 메일 확인이
안 끝난 경우입니다.
하려는 것
어디서
알아 두실 것
초대하기
Manage Account › Members › Invite
주소 여러 개를 한 번에 · 범위(scope) → 역할 → 요약 확인 → Invite
초대가 만료·분실
같은 화면 › 그 줄 › Resend Invite
⚠️ 만료까지 며칠인지는 공식 문서에 적혀 있지 않습니다(저희가 지어내지 않겠습니다)
메일 단계를 건너뛰기
같은 화면 › Direct Add
🟢 Enterprise 에서, 이미 Cloudflare 계정이 있는 사람이면 초대 메일 없이 바로 추가됩니다
권한 바꾸기
그 줄 › Edit › 역할·범위 → 요약 → Update
—
내보내기
그 줄 › Revoke → 확인
본인이 나가실 때는 자기 주소 아래 Leave
🔴 대시보드 관리자와 Zero Trust 사용자는 서로 다른 명단입니다.
여기 있는 사람은 설정을 바꾸는 관리자이고, 앱에 접속하는 직원은
좌석(Seats) 쪽에 있습니다. 그래서 여기서 지워도 앱 접속은 막히지 않고,
반대로 좌석을 회수해도 대시보드 권한은 그대로 남습니다.
좌석 쪽은 과
을 보십시오.
🔴 Super Administrator 가 한 분뿐이면, 그분이 잠기는 날 초대와 권한 변경이 함께 멈춥니다.
2단계 인증 복구는 영업시간에만 진행되므로 야간에 잠기면 그날 안에 풀리지 않습니다
().
그래서 두 명 이상을 권합니다 — 이것이 이 절에서 실제로 사고를 막는 한 줄입니다.
직접 해 보기 — 관리자 명단을 한 표로 보고, 멈춘 초대와 1인 의존을 찾습니다 (읽기만, 1분)
읽기 전용 토큰이면 됩니다. 아무것도 바뀌지 않습니다.
화면에서는 사람마다 줄을 펼쳐야 상태·역할·2단계 인증이 보입니다. 여러 명을 초대한 뒤에는 누가 아직 안 들어왔는지 세기 어렵고, Super Administrator 가 몇 명인지도 한눈에 안 잡힙니다. 이 도구가 그 셋을 한 표로 모으고, 위험한 조합(초대 멈춤 · 최고관리자 1명 · 2단계 인증 꺼진 최고관리자)을 따로 찍어 줍니다.
## 터미널에서 바로 받으시려면 (둘 중 아무거나)
curl -O https://waf-lab.mzc.cflare.kr/lab/py/members.py
wget https://waf-lab.mzc.cflare.kr/lab/py/members.py
파이썬 코드 보기 — members.py · 195줄 · 받기 전에 무엇을 하는지 읽어 보실 수 있습니다
안에 공식 API 문서 주소도 함께 적어 두었습니다. 위 「복사」 단추로 전문을 한 번에 복사하실 수 있습니다.
#!/usr/bin/env python3
"""대시보드 관리자 명단을 한 표로 봅니다 — 멈춘 초대·1인 의존·2단계 인증 (운영 매뉴얼 M20).
🟢 **읽기만 합니다.** 이 도구에는 지우거나 초대하는 기능이 아예 없습니다.
표준 라이브러리만 씁니다 — 설치할 것이 없습니다.
export CF_API_TOKEN=<토큰> # 권한: Account Settings Read (계정 · 읽기)
export CF_ACCOUNT_ID=<계정 ID>
python members.py
python members.py --json 다른 도구에 넘기실 때
무엇을 찾아 주나 — 화면에서는 한 사람씩 펼쳐야 보이는 것 셋입니다.
1) **수락되지 않은 초대**. 초대 메일은 만료됩니다. 화면의 「Invite Pending」 은
사람마다 줄을 펼쳐야 보이므로, 여러 명을 초대한 뒤에는 누가 안 들어왔는지
한눈에 세기 어렵습니다.
2) **Super Administrator 가 몇 명인가**. 관리자를 초대할 수 있는 사람은
Super Administrator 뿐입니다. 한 명이면 그 한 사람이 2단계 인증에서 잠기는 날
**초대도 권한 변경도 그날 안에 못 합니다.**
3) **2단계 인증이 꺼진 관리자**. 계정 API 가 사람마다
`two_factor_authentication_enabled` 를 알려 줍니다.
🔴 **이 도구가 답하지 못하는 것 — 초대가 언제 만료되는지.** 공식 문서는 「만료된다」와
「다시 보낼 수 있다」만 적고 **기간을 적지 않습니다.** 그래서 남은 날짜를 계산해
보여 주지 않습니다 — 지어낸 숫자를 운영 기준으로 쓰게 되기 때문입니다.
판단은 「수락 안 됨」이 보이면 다시 보내는 것으로 충분합니다.
🔵 **초대 대신 바로 넣는 길이 있습니다(Enterprise).** 이미 Cloudflare 계정이 있는
사람이면 메일 확인 단계를 건너뛰고 바로 추가할 수 있습니다(화면의 Direct Add).
초대 메일이 계속 만료되는 상황이라면 이 길이 더 빠릅니다.
⚠️ **대시보드 관리자와 Zero Trust 사용자는 다른 명단입니다.** 여기 나오는 사람은
설정을 바꾸는 관리자이고, 앱에 접속하는 직원은 좌석(seats) 쪽입니다.
좌석은 remove.py · inactive.py 를 보십시오.
공식 문서
멤버 관리 https://developers.cloudflare.com/fundamentals/manage-members/manage/
멤버 목록 API https://developers.cloudflare.com/api/resources/accounts/subresources/members/methods/list/
역할 목록 API https://developers.cloudflare.com/api/resources/accounts/subresources/roles/methods/list/
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
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, params=None):
url = API + "/accounts/" + ACCOUNT + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method="GET")
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:
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 members():
"""멤버 전부. 🔴 첫 장만 읽으면 조용히 일부만 봅니다 — total_pages 까지 따라갑니다."""
out, page = [], 1
while True:
d = call("/members", {"page": page, "per_page": 50})
out.extend(d.get("result") or [])
info = d.get("result_info") or {}
total = info.get("total_pages") or 1
if page >= total:
return out
page += 1
def role_names(m):
"""역할 이름. 예전 방식은 roles[], 범위를 지정한 방식은 policies[] 에 들어 있습니다.
🔴 **두 곳에 같은 이름이 같이 들어 있습니다**(실제 계정에서 확인). 그대로 이으면
같은 역할이 두 번 찍혀 「권한이 두 배로 있는 것처럼」 보입니다 — 그래서 중복을 걷어냅니다.
"""
names = [r.get("name", "") for r in (m.get("roles") or []) if r.get("name")]
for p in m.get("policies") or []:
for g in p.get("permission_groups") or []:
if g.get("name"):
names.append(g["name"])
seen, out = set(), []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
return out
def role_summary(names):
"""역할이 100개를 넘는 멤버가 실제로 있습니다 — 줄을 잘라 버리면 정작 중요한
Super Administrator 가 안 보입니다. 그래서 최고 권한을 앞으로 끌고 개수를 함께 냅니다."""
if not names:
return "(역할 없음)"
first = [n for n in names if "super administrator" in n.lower()] or names[:1]
rest = len(names) - 1
return first[0][:44] + (" 외 %d개" % rest if rest > 0 else "")
def is_super(m):
return any("super administrator" in n.lower() for n in role_names(m))
def main():
ap = argparse.ArgumentParser(description="대시보드 관리자 명단 점검 (읽기만)")
ap.add_argument("--json", action="store_true", help="사람이 읽는 표 대신 JSON")
a = ap.parse_args()
rows = []
for m in members():
user = m.get("user") or {}
# 🔴 status 의 글자를 단정하지 않습니다. 「accepted 가 아니면 아직 안 들어온 것」
# 으로만 봅니다 — 문자열이 바뀌어도 판정이 틀리지 않습니다.
status = m.get("status") or "?"
rows.append({
"email": m.get("email") or user.get("email") or "?",
"status": status,
"accepted": status == "accepted",
"super": is_super(m),
"two_factor": bool(user.get("two_factor_authentication_enabled")),
"api_access_enabled": bool(m.get("api_access_enabled")),
"roles": role_names(m),
"member_id": m.get("id"),
})
rows.sort(key=lambda r: (r["accepted"], not r["super"], r["email"]))
pending = [r for r in rows if not r["accepted"]]
supers = [r for r in rows if r["super"]]
super_no_2fa = [r for r in supers if not r["two_factor"]]
if a.json:
print(json.dumps({
"members": rows,
"pending": [r["email"] for r in pending],
"super_admins": [r["email"] for r in supers],
"super_admins_without_2fa": [r["email"] for r in super_no_2fa],
}, ensure_ascii=False, indent=2))
return
print("대시보드 멤버 %d명" % len(rows))
print("%-38s %-12s %-5s %-5s %s" % ("이메일", "상태", "최고", "2단계", "역할"))
for r in rows:
print("%-38s %-12s %-5s %-5s %s" % (
r["email"],
"수락" if r["accepted"] else "수락 안 됨",
"예" if r["super"] else "",
"켜짐" if r["two_factor"] else "꺼짐",
role_summary(r["roles"]),
))
print("")
if pending:
print("🔴 수락되지 않은 초대 %d건 — 다시 보내 주십시오(화면: Members › 해당 줄 › Resend Invite)."
% len(pending))
for r in pending:
print(" · " + r["email"])
else:
print("🟢 멈춰 있는 초대는 없습니다.")
if len(supers) == 0:
print("⚠️ Super Administrator 를 찾지 못했습니다 — 토큰 권한이 좁아 역할이 안 보일 수 있습니다.")
elif len(supers) == 1:
print("🔴 Super Administrator 가 1명(%s)뿐입니다. 그 한 분이 2단계 인증에서 잠기면"
% supers[0]["email"])
print(" 초대·권한 변경이 그날 안에 안 됩니다. 2명 이상을 권합니다.")
else:
print("🟢 Super Administrator %d명 — 한 사람이 잠겨도 나머지가 풀 수 있습니다." % len(supers))
if super_no_2fa:
print("🟠 2단계 인증이 꺼진 Super Administrator %d명 — 켜는 것을 권합니다."
% len(super_no_2fa))
for r in super_no_2fa:
print(" · " + r["email"])
if __name__ == "__main__":
main()
🔴 이 도구에는 지우거나 초대하는 기능이 아예 없습니다 — 읽기만 합니다. 권한은 Account Settings Read 하나면 됩니다. 목록이 여러 장으로 나뉘어 오므로 끝 장까지 따라가야 하고(첫 장만 읽으면 관리자 몇 명이 조용히 빠집니다), 역할 이름이 두 곳에 같이 들어 있어 중복을 걷어냅니다(실제 계정에서 확인했습니다 — 한 분은 역할이 131개였습니다).
이 절은 읽는 분이 아니라 직원분들을 위한 것입니다. 2차 인증을 켜기로 정하셨으면
남는 일은 「전 직원에게 무엇을 어떻게 안내하는가」입니다. 그 문장을 아래에 그대로 써 두었습니다 —
고칠 곳은 한 곳(팀 이름)뿐입니다.
💡 팀 이름은 어디서 보나 — 대시보드 Access controls › Access settings 의
「Manage your App Launcher」 설명줄에 여러분팀이름.cloudflareaccess.com 형태로
적혀 있습니다. 아래 공지문의 <팀이름> 두 곳을 그 값으로 바꾸시면 됩니다.
딥링크(내 계정) ↗
① 직원용 — 그대로 복사해 보내시면 됩니다
[사내 안내] 회사 시스템 접속에 휴대폰 인증(2차 인증)이 추가됩니다
무엇이 바뀌나요
회사 계정으로 로그인한 뒤, 휴대폰 인증 앱의 6자리 숫자를 한 번 더 넣게 됩니다.
비밀번호가 바뀌는 것은 아니고, 로그인 뒤에 한 단계가 더 생기는 것입니다.
미리 해 두실 일 (3분)
1. 휴대폰에 인증 앱을 설치합니다.
(Microsoft Authenticator, Google Authenticator, Authy 중 아무거나)
2. PC 브라우저로 아래 주소를 엽니다.
https://<팀이름>.cloudflareaccess.com/AddMfaDevice
3. 회사 계정으로 로그인합니다.
4. 「Authenticator application」을 고릅니다.
5. 화면의 QR 코드를 휴대폰 인증 앱으로 찍습니다.
6. 앱에 나오는 6자리 숫자를 화면에 입력하고 저장합니다.
다음 로그인부터
회사 로그인을 마치면 6자리를 묻는 화면이 나옵니다.
인증 앱을 열어 그때 보이는 숫자를 넣으시면 됩니다.
숫자는 30초마다 바뀌며, 지나간 숫자는 쓸 수 없습니다.
휴대폰을 바꾸거나 잃어버렸을 때
다른 인증 수단(보안키·지문 등)을 등록해 두셨으면 그것으로 본인 확인 후
https://<팀이름>.cloudflareaccess.com 의 Account 화면에서 직접 지우고 다시 등록하실 수 있습니다.
등록한 수단이 인증 앱 하나뿐이었다면 직접 지울 수 없습니다 — 담당자에게 알려 주십시오.
문의
[담당 부서·연락처를 적어 주십시오]
🔴 「인증 앱을 두 대 이상의 휴대폰에 쓰겠다」는 분이 있으면 등록할 때
같은 QR 코드를 각 휴대폰으로 찍게 하십시오. 나중에 한 대를 추가로 등록하는 방법은 없습니다
(인증 앱은 사람당 하나입니다 — ).
② 운영 담당자용 — 배포하면 실제로 오는 요청 셋
요청
답
어디서
「등록 주소를 열었는데 관리자에게 문의하라고 나옵니다」
🔴 App Launcher 에 정책이 없는 것입니다. 직원 문제가 아닙니다 — 아래 ③을 먼저 하십시오
Access settings › App Launcher
「개인 메일로 등록이 안 됩니다」
등록 관문이 회사 도메인만 허용하도록 되어 있으면 그것이 정상입니다. 개인 주소는 거절됩니다
같은 App Launcher 정책
「휴대폰을 바꿨는데 못 들어갑니다」
수단이 하나뿐이면 관리자가 지워 줘야 합니다. 지우면 다음 접속에 재등록 화면이 뜹니다.
🔴 백업 코드는 없습니다
에 절차와 스크립트
🔵 요청을 줄이는 방법은 하나입니다 — 안내문에 「수단을 두 개 등록해 두시면 휴대폰을
바꾸셔도 직접 처리하실 수 있습니다」를 넣는 것입니다. 인증 앱은 하나지만
보안키·생체는 여러 개 등록됩니다. 그러면 위 세 번째 요청이 거의 사라집니다.
③ 관리자용 — 안내문을 보내기 전에 확인할 것 셋
🔴 순서가 거꾸로 되면 전 직원이 같은 자리에서 막힙니다. 아래 셋을 먼저 끝내십시오.
확인할 것
안 해 두면
1
App Launcher 에 정책이 하나라도 있는지 Access settings › Manage your App Launcher › 정책 추가(예: 사내 도메인 허용) 🔴 기본값이 꺼짐이라 손대지 않은 계정에는 이 앱이 아예 없습니다.
그리고 이 정책은 포털 문만 엽니다 — 뒤에 있는 앱 권한과는 별개입니다
(화면 경로·딥링크·캡처는 「② 직접 해보기」 카드의 1단계에 있습니다)
등록 화면이 그 안에 있어서, 직원이 주소를 열어도 「관리자에게 문의」에서 끝납니다
2
쓸 수 있는 수단을 켰는지 Access settings › Allow multi-factor authentication — 인증 앱·보안키·생체
고를 수단이 없어 등록 화면이 빈 채로 뜹니다. 최소한 인증 앱은 켜야 합니다
3
어느 앱에 2차 인증을 걸지 정했는지 앱 › Configure › Authentication — 조직 값 따름 / 앱별로 정하기 / 끄기
켜 두고 아무 앱에도 걸지 않으면 직원은 등록만 하고 아무 일도 일어나지 않습니다
⚠️ 인증 지속시간(한 번 통과하면 얼마 동안 다시 안 묻나)도 같은 화면에 있습니다.
규정이 짧으면 여기부터 줄이시고, 「매 접속마다」가 필요하면 그 값을 0 으로 두십니다.
이 값은 로그인 세션 넷과 별개의 시계입니다 —
을 함께 보십시오.
🔵 이 안내문에는 회사 이름도 계정 값도 들어 있지 않습니다.<팀이름> 두 곳만 바꾸시면 그대로 쓰실 수 있고, 사내 공지·위키에
붙여 넣으셔도 됩니다. 직원분들께 이 페이지 주소를 그대로 보내셔도 됩니다 —
로그인 없이 열립니다.
Cloudflare 는 단말 안이 아니라 단말과 서버 사이 길목에 섭니다. 그래서 앱 설치·실행은 막지 못하고,
앱이 서버와 통신하는 순간을 막습니다. 내용 검사(DLP)는 그 통신을 풀어서(TLS 복호화) 볼 수 있을 때만 됩니다 —
«앱이냐 웹이냐»가 아니라 «복호화가 되느냐»가 기준입니다.
화면: Traffic policies › Firewall policies(DNS · Network · HTTP 탭) · 딥링크(내 계정, HTTP) ↗ 앱 차단은 DNS·Network 탭, DLP·격리는 HTTP 탭입니다. DLP 탐지 기준은 Data loss prevention › Profiles 에 있습니다.
① 무엇이 어디까지 되나
기능
하는 일
앱에서도 되나
앱 차단 DNS · Network
앱 이름(Application)으로 통신을 끊습니다. 도메인을 하나씩 적지 않아도 Cloudflare 가 목록을 관리합니다
✅ 복호화 없이 됩니다
기록
누가·언제·어느 앱/사이트에 붙었는지 사용자·단말별로 남습니다
✅ 접속 사실은 남습니다. 내용은 아닙니다
DLP HTTP
올리거나 보내는 내용에서 주민번호·카드번호·자격증명 같은 민감정보를 찾아 막습니다
⚠️ 복호화되는 HTTP 만 — 브라우저·웹은 되고, 인증서 고정 앱·웹이 아닌 프로토콜은 안 됩니다
격리 브라우저 HTTP · Isolate
웹을 원격 브라우저로 열고 붙여넣기·업로드·다운로드·인쇄를 막습니다
웹 전용입니다. 유료 애드온(Pay-as-you-go·Enterprise) — Free 계정에는 없습니다
💡 왜 모바일 앱은 DLP 가 안 걸리나 — 대부분의 모바일 앱은 인증서 고정(certificate pinning)을 씁니다.
Cloudflare 인증서를 거부하므로 복호화하면 앱이 깨집니다. Cloudflare 앱 목록에 … (Do Not Inspect)
항목이 따로 있는 앱(예: ChatGPT · Kakao Talk)이 그런 구간입니다. 이런 앱은 «Do Not Inspect» 로 빼야 하고, 빼면 DLP·HTTP 기록도 함께 빠집니다.
그래서 앱은 통째로 막거나 허용하고, 내용 통제가 필요하면 웹으로 쓰게 해서 DLP·격리를 겁니다.
② 우리 랩에서 확인한 것 (2026-10-15)
시험
구성
결과
ChatGPT 차단 Free 계정 · 페르소나 6명
DNS 정책 any(app.ids[*] in {1199 1862}) Block + 같은 조건의 Network 정책 Block.
업무 대역만 터널을 타는 단말(Include 모드)도 DNS 는 Gateway 를 거치므로 DNS 정책이 걸립니다
✅ 단말에서 chatgpt.com·openai.com → 162.159.36.12(Gateway 차단 응답),
대조군 example.com 은 정상
DLP · 격리 격리 권한이 있는 데모 계정 · 시험 사용자만
G0 시험 사용자 외 Do Not Inspect → G1 인증서 고정 앱 분류 Do Not Inspect → G2 ChatGPT Isolate(붙여넣기·업로드·다운로드·인쇄 차단)
→ G3 DLP 프로필(주민번호·핸드폰 번호·PII·자격증명) Block. 그다음 TLS 복호화를 켰습니다
정책 적용까지 확인. 단말 시험은 아래 ③ 체크리스트로 합니다
③ 해 보기 — 본인 계정에서 확인하는 순서
앱 차단 — DNS 정책 «Application in ChatGPT» Block, Network 정책에 같은 조건 Block.
단말에서 nslookup chatgpt.com → 162.159.36.12 이면 막힌 것입니다. 앱을 열면 실행은 되고 대화가 안 됩니다.
범위부터 좁힙니다 — 여러 사람이 쓰는 계정이면 «시험 사용자 목록 밖 = Do Not Inspect» 정책을 복호화를 켜기 전에 만듭니다.
TLS 복호화를 켜고 시험 단말에 Cloudflare 루트 인증서를 설치합니다. 인증서가 없으면 HTTPS 사이트가 인증서 오류로 깨집니다.
DLP — HTTP 정책 «DLP Profile in (개인정보 프로필)» Block. 아무 웹 폼에 주민번호 형식의 값(실제 번호 말고)을 넣어 보내면 차단 화면이 떠야 합니다.
격리 — HTTP 정책 «Application in ChatGPT» Isolate + 붙여넣기·업로드 «차단». chatgpt.com 이 격리 화면으로 열리고, 복사해 둔 글을 붙여넣으면 들어가지 않아야 합니다.
클라이언트 없이 보려면 https://<팀이름>.cloudflareaccess.com/browser/https://chatgpt.com 으로 엽니다(설정 «클라이언트 없이 원격 브라우저 열기»가 켜져 있어야 합니다).
기록 — Insights › Logs › HTTP request logs 에서 정책 이름으로 거릅니다. 차단·격리가 정책 이름과 함께 남아 있으면 끝입니다.
🔴 함정 셋 —
① TLS 복호화는 계정 전체 설정입니다. 범위를 좁히는 Do Not Inspect 를 먼저 만들지 않으면 그 계정의 모든 단말이 영향을 받습니다.
② Do Not Inspect 는 목록 순서와 상관없이 가장 먼저 평가되고, 걸린 트래픽은 평문 HTTP 까지 나머지 HTTP 정책을 건너뜁니다.
③ 브라우저가 HTTP/3(QUIC)로 붙으면 HTTP 정책이 안 걸릴 수 있습니다 — Gateway 의 UDP 프록시를 켜거나 브라우저에서 QUIC 를 끕니다.
🔴 여기 적힌 것은 우리 랩에서 직접 지우고 켜 보고 확인한 것이거나 화면이 기본값으로 감추는 지점입니다.
「해 보기」가 붙은 절에는 내려받아 그대로 돌릴 수 있는 파이썬 파일이 들어 있습니다 —
대부분 읽기만 하고, 바꾸는 것은 --apply 를 붙일 때뿐입니다.
M4·M8 은 시험용 단말로만 하십시오.
시험 환경 — 구성도 · 사용자별 정책 · 접속·시험 순서
우리 랩 실측 2026-10-14~15 · 회사·고객 값 없음
이 탭의 실습·매뉴얼 내용을 실제 단말로 확인하려고 만든 랩입니다. 소속별 접근 통제와 앱 차단은
GH 랩(Windows·Android 단말 + 가상 업무서버)에서, DLP·격리 브라우저는 권한이 있는 데모 계정에서 봅니다.
계정 ID·사람 이메일·내부 주소·비밀번호는 가렸습니다(<…>).
개념 설명은 운영 매뉴얼 «소속별 접근 랩» 과
M22 «앱은 설치·실행되는데 쓸 수 없다» 에 있습니다.
💡 왜 계정이 셋인가 — 정책은 랩 계정(시험받는 쪽)에 두고, 단말 화면 보기는 다른 데모 계정의 팀 로그인 뒤에 둡니다(보는 쪽).
랩 계정은 Zero Trust Free 라 격리 브라우저가 없어서(유료 애드온), DLP·격리는 권한이 있는 데모 계정 B 에 따로 만들었습니다. 셋 다 Pulumi 코드로 만들었습니다.
서버 주소는 랩 대역의 사설 주소라 WARP 밖에서는 이름을 알아도 열리지 않습니다 — 주소 링크는 WARP 에 연결된 단말(화면 보기) 브라우저에서 누르십시오.
웹에서 막히면 안내판의 «막혔습니다» 화면으로 넘어가고, 막은 정책·허용 정책·로그 링크가 함께 뜹니다.
순서
종류
Gateway 정책(랩 계정)
동작
대상
5100
DNS
메신저(카카오톡) 차단 — 그룹 IT 예외
차단
그룹 IT 외 5개 소속
5101
DNS
ChatGPT 차단 — any(app.ids[*] in {1199 1862})
차단
6개 소속
5105
L4
ChatGPT 차단(네트워크) — 같은 조건
차단
6개 소속(전체 터널 단말에서 실효)
5110
L4
SSH(22) — 그룹 IT 만 허용
허용
그룹 IT
5120
L4
랩 서버 웹(80) — 페르소나 통과(소속 판정은 HTTP)
허용
6개 소속
5190
L4
All Block — 랩 대역의 나머지 전부
차단
전원
5200~5205
HTTP
서버별 허용 소속(위 표) · ERP 는 단말 조건 미충족을 먼저 차단(5201)
허용
서버마다
5280
HTTP
랩 안내판 — 전 소속 허용
허용
6개 소속
5290
HTTP
랩 서버 — 권한 없는 소속 차단(안내판 차단 화면으로)
차단
나머지
단말 조건
기준
Windows OS 버전
≥ 10.0.19045 (Server 2022 = 10.0.20348 통과)
Windows 방화벽
켜짐
Android OS 버전
≥ 13.0.0 (에뮬레이터 14 통과 — 15 로 올리면 막힘)
DLP·격리(데모 계정 B, HTTP)
동작
G0 시험 사용자 목록 밖 전원
검사 안 함 — 공용 계정 보호
G1 시험 사용자 · 앱 분류 Do Not Inspect(16)
검사 안 함 — 인증서 고정 앱
G2 시험 사용자 · ChatGPT(1199)
격리 — 붙여넣기·복사는 격리 안에서만, 업로드·다운로드·인쇄 차단
G3 시험 사용자 · DLP 프로필(주민번호·핸드폰 번호·PII·자격증명)
차단
🔴 Windows 백신(Security Center) 조건은 없습니다 — 랩 단말이 Windows Server 라 Security Center 가 없습니다. 실제 PC(Windows 10·11)에서 따로 봅니다.
데모 계정 B 의 TLS 복호화는 계정 전체 설정이라, G0 을 먼저 만들고 나서 켰습니다(M22 함정 ①②).
대시보드 화면 — 어디서 설정하나
아래 링크는 계정 자리를 비운 딥링크입니다. 열면 «Select an account to continue» 가 먼저 뜨고, 본인 계정을 고르면 그 화면으로 바로 갑니다.
화면은 우리 랩 계정에서 2026-10-15 에 찍었고, 계정 이름과 랩과 무관한 항목은 가렸습니다. 그림을 누르면 원본 크기로 열립니다.
DNS 정책 — 카카오톡 차단(그룹 IT 예외) · ChatGPT 차단(전 소속). 복호화 없이 이름 조회 단계에서 끊습니다. Traffic controls › Firewall policies › DNS · 딥링크(내 계정) ↗ChatGPT 차단 정책 안 — Traffic: Application in ChatGPT · Identity: User Email in list «전체 페르소나» → Block.
사람은 목록에만 넣고 정책은 목록만 봅니다.
정책 목록에서 이름을 누르고 Edit · 딥링크(내 계정) ↗Network(L4) 정책 — ChatGPT 차단 · SSH 22 는 그룹 IT 만 · 랩 웹 80 통과 · 랩 대역 나머지 차단(All Block). 위에서 처음 맞는 정책에서 멈춥니다. Traffic controls › Firewall policies › Network · 딥링크(내 계정) ↗HTTP 정책 — 서버마다 허용 소속, ERP 는 단말 조건 미충족을 먼저 차단, 끝에서 권한 없는 소속 차단. 랩 서버는 평문 HTTP 라 TLS 복호화 없이 걸립니다(노란 안내는 HTTPS 이야기). Traffic controls › Firewall policies › HTTP · 딥링크(내 계정) ↗단말 프로필 — 본사·그룹 IT = 전체 터널(Split Tunnels Exclude) · 협력사·고객사 = 업무 대역만(Include) · 나머지 = Default. 어느 프로필을 받았는지는 «Your devices» 탭에서 봅니다. Team & Resources › Devices › Device profiles · 딥링크(내 계정) ↗단말 조건(posture) — Android OS 버전(≥13) · Windows 방화벽 켜짐 · Windows OS 버전(≥10.0.19045). ERP 정책이 이 결과를 봅니다. Reusable components › Posture checks · 딥링크(내 계정) ↗목록(Lists) — 소속별 이메일 목록(User Emails) 6 + 전체 페르소나 1 · 서버별 IP 목록 5. 사람·서버를 더할 때는 여기만 고칩니다. Reusable components › Lists · 딥링크(내 계정) ↗DLP·격리 정책(데모 계정 B) — G0·G1 Do not inspect → G2 Isolate → G3 Block. Do not inspect 는 목록 순서와 상관없이 가장 먼저 평가됩니다. Traffic controls › Firewall policies › HTTP · 딥링크(내 계정) ↗G2 격리 정책 안 — Application in ChatGPT + 시험 사용자 목록 → Isolate. Isolation settings: 복사·붙여넣기 «격리 브라우저 안에서만», 키보드 허용, 인쇄·다운로드·업로드 «Do not allow».
정책 이름 → Edit · 딥링크(내 계정) ↗G3 DLP 정책 안 — DLP Profile in Credentials and Secrets · PII Record · 주민번호 · 핸드폰 번호 + 시험 사용자 목록 → Block. 걸린 값을 남기려면 «Log the payload of matched rule» 을 켭니다(암호화 키 필요).
정책 이름 → Edit · DLP 프로필은 Data loss prevention › Profiles · 딥링크(내 계정) ↗
버튼을 누르면 계정 선택 → «Create new firewall policy» 양식이 이 값으로 채워진 채 열립니다. «Create policy» 를 누르기 전까지는 아무것도 저장되지 않습니다.
사람·서버 값은 예시(user1@example.com, 192.0.2.10)이니 본인 값으로 바꾼 뒤 저장하십시오. 우리 랩 계정에서 종류별로 열어 채워지는 것을 확인했습니다(2026-10-15).
종류
템플릿
채워지는 값
바꿀 것
열기
DNS
메신저(카카오톡) 차단
Application in Kakao Talk · User Email in 예시 2명 → Block
사용자 이메일(또는 «User Email in list» 로 바꿔 목록 선택). 예외 소속이 있으면 그 사람을 빼거나 «not in list»
💡 동작 이름은 화면 표기 그대로 채워집니다. 단말 조건(posture)·목록(Lists)은 계정마다 ID 가 달라 템플릿에 넣지 않았습니다 — 양식에서 «+ Add condition» 으로 고르십시오.
대시보드 정책 메뉴의 «Duplicate» 는 양식이 아니라 복제본을 바로 만듭니다 — 시험 삼아 누르지 마십시오.
③ 접속·시험 순서
시나리오별 — 어디서, 어떤 계정으로 들어가면, 무엇이 열리고 막히나. 계정 이메일·비밀번호는 팀 내부에서 보관합니다(<본사 계정> 처럼 가렸습니다). 막히면 무엇이 막았는지 안내판 차단 화면과 로그로 확인합니다.
현장에서 꼭 나오는 질문 4가지 — 단말 수 · 2차 인증 · 세션과 재부팅 · One 과 WARP 의 차이
공식 문서 2026-09-09 재확인 + Demo2 실측
Q1사용자당 단말 수를 제한할 수 있나 (Device limit)
없습니다. Device registration 문서에 상한·자동 만료 항목이 없습니다. 등록은 관리자가 지우기 전까지 누적되고,
지워도 그 단말에서 다시 인증하면 새 등록이 생깁니다(「Deleting one registration does not affect other registrations on the same device」).
자동 정리는 사용자 단위뿐 — Settings › Admin controls › Remove inactive users from seats(1개월~1년, 매일 검사, 인증 이벤트와 단말 Last seen 둘 다 없을 때).
활성 사용자에게 쌓인 옛 단말은 API(GET/DELETE /devices)로 스크립트 정리 — dry-run 부터.
문서: Device registration ·
Seat management
Q22차 인증은 어떻게 접속하고, 안 될 땐 어떻게 하나
위 두 카드가 그 답입니다 — 등록은 <team>.cloudflareaccess.com/AddMfaDevice, 로그인 때 IdP 뒤에 6자리.
안 될 때 1순위 원인은 App Launcher 가 꺼진 것(등록 화면이 그 안에 있습니다 — Demo2 실측). 2순위는 휴대폰 분실·교체 →
관리자가 Users › 사용자 › MFA devices › Delete → 다음 접속에 재등록. 복구 코드는 없고, 수단 2개 이상 등록이 예방책.
공용 계정은 같은 QR 을 여러 휴대폰에 등록.
문서: Independent MFA
단말(WARP) 세션은 여기서 정합니다 — Demo2 기본 8 hours, 화면 안내 「36시간을 넘기지 말 것」. IdP 재인증 시점부터 고정으로 흐르고 사용 중이라고 연장되지 않습니다.
앱에 「Authenticate with Cloudflare One Client」가 켜져 있으면 이 값이 앱·정책·전역 세션을 전부 이깁니다 — 「24시간 지났는데 안 묻습니다」의 원인이 대개 이것.
만료되면 다음 접속에 IdP 재로그인, 이미 열린 SSH/RDP 는 안 끊깁니다. 재부팅 강제 로그오프 기능은 없습니다(2026-09-09 재확인). 가장 가까운 조합 셋:
① 단말 세션을 근무 주기보다 짧게(8~10h) ② MFA 지속시간 「Require every login」
③ Windows MDM no_auth_no_internet(WARP 2026.5.0+, 2026-05 신설) — 미인증 상태면 부팅 직후부터 인터넷을 막고 인증해야 풉니다.
단 「매 부팅마다 인증」이 아니라 「미인증이면 차단」이라 세션이 살아 있으면 재부팅해도 안 묻습니다 → ①과 묶어야 「부팅 = 로그인」에 근접. 제약: Windows 전용 · 인증 창이 WebView2 라 기본 브라우저 의존 IdP 는 실패 가능.
문서: Session management ·
Client sessions ·
no_auth_no_internet
Q4Cloudflare One 하고 WARP 는 뭐가 다른가 · WARP 는 없어지나
층이 다릅니다.Cloudflare One 은 제품군 전체를 부르는 우산 이름(Access·Gateway·DLP·Tunnel…)이고,
WARP 는 그중 단말에 까는 프로그램 하나입니다 — 트래픽을 Cloudflare 로 끌어오는 진입로 역할이라,
같은 자리에 Tunnel·GRE·IPsec 도 들어옵니다.
없어지지 않습니다 — 이름만 One 쪽으로 합쳐졌습니다. 데스크톱은 Cloudflare One Client(공식 문서 표기 「formerly WARP」),
모바일은 Cloudflare One Agent(옛 1.1.1.1 앱을 대체). 기능·요금은 그대로입니다.
🔴 속은 아직 전부 warp 입니다 — cloudflare-warp,
service_mode: warp 같은 설치·배포 값을 새 이름으로 치환하면 깨집니다.문서: 클라이언트 내려받기
세션 4층(단말 › 정책 › 앱 › 전역)은 「활동으로 연장」이 아니라 「인증 시점부터 고정 만료」입니다 — 규정에 맞추려면 단말 세션과 전역 세션 두 값을 줄입니다.
실습용 IdP 는 어떻게 만들었나「진짜인가 흉내인가」에 답합니다
로그인 화면에 실습용 IdP (a) · 발급자 https://idp.nxlab.cflare.kr/a 라고만 뜨면,
보는 사람은 이게 진짜 OIDC 인지 화면만 흉내 낸 것인지 알 수 없습니다. 답부터 드리면 —
표준 그대로입니다. Cloudflare Access 가 이 서버의 서명을 실제로 검증하고, 통과해야 로그인이 됩니다.
아래는 무엇으로 어떻게 만들었는지입니다.
1「진짜 OIDC 인가」의 답은 ⑧⑨⑩ 세 칸에 있습니다.
화면만 흉내 낸 서버라면 ⑧에서 서명을 만들지 못합니다. Access 는 ⑨로 가져온 공개키로 그 서명을 맞춰 보고,
맞지 않으면 로그인을 시키지 않습니다 — 우리가 통과시키는 것이 아니라 Cloudflare 가 검사해서 통과시키는 것입니다.2IdP 를 셋 세운 이유는 ⑤ 절 때문입니다.
로그인 화면이 갈리는 것을 보려면 「IdP 가 여럿인 상태」가 필요한데, 정작 필요한 것은 그 상태이지 특정 벤더가 아니었습니다.
그래서 t3.micro 한 대에 프로세스 하나로 그 상태를 만들었습니다.
왜 Keycloak 을 안 쓰고 직접 만들었나
랩 EC2 는 t3.micro(총 913MB · 여유 438MB)이고, 그 위에서 WAF 실습장 오리진과 러너 다섯이 이미 돌고 있습니다.
Keycloak·Authentik 은 그것들을 내려야 올라갑니다 — 실습장을 세우려고 실습장을 끄는 셈입니다.
그리고 필요한 것은 「IdP 가 여럿입니다」라는 상태이지 특정 벤더가 아니었습니다.
그래서 의존성 0 · 프로세스 1개짜리 서버를 Node 기본 모듈만으로 짰습니다.
한 프로세스가 IdP 셋인 척합니다 — 경로로 갈립니다
경로
프로토콜
Access 에는 이렇게 보입니다
/a
OIDC
메가존 사내 IdP (랩)
/b
OIDC
협력사 IdP (랩)
/saml
SAML 2.0
레거시 SAML IdP (랩)
Access 에는 완전히 별개의 IdP 셋으로 등록돼 있습니다.
그래야 로그인 화면에 버튼이 여러 개 생기고, ⑤ 절에서 보는 「화면이 왜 다른가」가 성립합니다.
표준을 실제로 구현한 것
엔드포인트
무엇을 하나
/a/.well-known/openid-configuration
OIDC discovery — Access 가 여기부터 읽습니다
/a/jwks
공개키. Access 가 id_token 서명을 검증하는 근거
/a/authorize
「누구로 로그인할지」 고르는 화면 · authorization code 발급
/a/mfa
2단계 — TOTP 또는 지문·보안키
/a/token
code → RS256 서명 id_token 교환
/a/userinfo
토큰으로 신원 조회
/saml/metadata · /saml/sso
SAML 2.0 — HTTP-Redirect 로 요청 받고, 서명한 Assertion 을 HTTP-POST 로 ACS 에 보냅니다
/zt
실습 앱 오리진. Access JWT 를 서버에서 풀어 어느 IdP · amr · MFA 여부를 사람이 읽게 보여 줍니다
/signup · /whoami · /health
실습 계정 만들기 · 지금 누구인지 · 살아 있나
2단계 인증은 흉내가 아닙니다
처음에는 「여섯 자리면 통과」 였습니다. 그건 2단계를 배우는 데 아무 쓸모가 없어서 걷어냈습니다.
지금은 RFC 6238 그대로입니다 — 사용자마다 비밀키를 만들어 otpauth:// QR 로
인증 앱(Google·Microsoft Authenticator)에 등록하고, HMAC-SHA1 · 30초 창으로 검증하며,
같은 코드는 두 번 받지 않습니다. 등록은 파일에 남아 서버를 다시 켜도 유지됩니다.
지문 · 얼굴 · 보안키(WebAuthn)도 실제로 됩니다 — Windows Hello · Touch ID · USB 보안키.
실제 AD FS 가 Windows Hello 를 지원하므로, 랩이 AD FS 를 흉내 내려면 이게 있어야 맞기 때문입니다.
🔴 이메일 코드·푸시 승인 흉내는 지웠습니다. 진짜가 아닌 것은 화면에 두지 않습니다 — 그게 이 실습장의 규칙입니다.
🔴 이 서버는 랩 전용입니다 — 비밀번호를 묻지 않습니다.
「누구로 로그인할지」를 버튼으로 고릅니다. 사람을 인증하는 것이 목적이 아니라,
Access 로그인 화면이 설정에 따라 어떻게 달라지는지를 보는 것이 목적이기 때문입니다.
그래서 이 서버는 실습 네트워크 밖으로 나가면 안 됩니다 — Cloudflare 프록시 뒤, 랩 존 안,
127.0.0.1:8093 로만 듣습니다. 회사 계정·고객 계정과는 완전히 다른 계정이고,
여기서 만든 신원으로는 실습 앱 말고는 아무 데도 못 들어갑니다.
서버는 의존성 0 · 한 프로세스짜리 Node 입니다 · 호스트 idp.nxlab.cflare.kr (우리 랩 EC2)
같은 계정 · 같은 사람인데, 앱 설정만 다르면 로그인 화면이 달라집니다
2026-09-08 실측
아래 넷은 같은 Cloudflare 계정의 앱이고 정책도 같습니다(사내 도메인 허용). 다른 것은
허용 IdP 조합과 Access MFA 여부뿐입니다. 눌러 보면 화면이 어떻게 갈리는지 바로 보입니다 —
IdP 는 실습용이라 비밀번호를 묻지 않고 「누구로 로그인할지」를 고릅니다.
같은 문지기인데 앱 설정에 따라 첫 화면이 셋으로 갈립니다. 아래 표의 링크를 눌러 A·B·C 를 실제로 비교해 봅니다.
로그인이 끝나면 통과 화면이 뜨는데, 거기 amr 값이 「2단계를 진짜로 거쳤나」의 유일한 증거입니다.
🔵 IdP 가 셋이라 계정도 셋처럼 보이지만 하나입니다 —
한 번 가입하면 A·B·C 어디서나 같은 이메일·비밀번호로 들어갑니다.
무엇이 어디에 저장되는지는 ② 직접 해보기 절의 「어느 실습이 어느 IdP 로 가나」 ↓ 에 표로 정리해 두었습니다.
버튼 셋 + 「or」 + 이메일 입력칸. One-time PIN 도 IdP 하나로 취급되므로
허용 목록에 그 id 를 넣어야 이 칸이 생깁니다(안 넣으면 버튼만 셋).
IdP 가 거는 2단계는 어디서 갈리나
위 카드의 B 이야기입니다(Access 가 직접 거는 것은 그림 2 의 A). IdP 가 걸면 같은 앱이라도
어느 IdP 로 들어왔느냐에 따라 2단계가 강제되기도, 건너뛰어지기도 합니다.
랩 IdP 는 셋의 정책을 일부러 다르게 두었습니다. 셋 다 진짜 TOTP(QR 등록 · HMAC 검증)입니다:
IdP 가 「어떻게 인증했는지」(amr)가 Access 를 지나 오리진까지 그대로 옵니다.
Access 정책의 「인증 방식(Authentication Method)」 규칙이 보는 값이 이것이고,
SAML 쪽에서는 AuthnContextClassRef 가 같은 자리를 맡습니다.
2단계를 건너뛴 토큰에는 ["pwd"] 만 실립니다 — 정책으로 가르는 근거가 됩니다.
2단계를 건너뛰면 토큰이 달라집니다 — 나란히 비교
어떻게 들어왔나
오리진이 받은 값
사내 IdP · TOTP 통과
amr: ["pwd","otp","mfa"] · acr: …:mfa
협력사 IdP · 「2단계 없이 진행」
amr: ["pwd"] · acr: …:pwd — 같은 앱·같은 정책인데 값이 다릅니다
SAML IdP · 2단계 통과
groups 와 mfa: "true" 가 어서션 속성 그대로 넘어옵니다
여기가 정책이 붙는 자리입니다. 「MFA 를 거친 사람만」이라는 규칙은 Access 가 이 값을 보고 판정합니다 —
IdP 를 믿는 것이 아니라 IdP 가 「어떻게 인증했습니다」고 말한 내용을 보는 것입니다.
SAML 을 직접 붙일 때 밟는 함정
해결
서명 대상 XML 에 빈 태그(<x/>)를 쓰면 검증이 깨집니다
exclusive c14n 의 정규형은 <x></x> 라, 자기닫힘으로 서명해 두면
검증하는 쪽 다이제스트와 어긋납니다. Access 는 그 실패를
Failed to fetch user/group information from the identity provider 라는
「사용자 정보를 못 읽었습니다」 문구로 보여 줘서 원인이 서명이라는 것이 드러나지 않습니다.
2026-09-08 이 랩에서 그대로 겪었고, 빈 태그를 짝 태그로 바꾸자 바로 통과했습니다.
IdP 서버: idp.nxlab.cflare.kr · 랩 EC2 · 실습 뒤 회수 대상
사례실제 문의에서 나온 것 4건 — 제목을 누르면 창으로 열립니다
이렇게 보입니다클라이언트가 연결 중에 멈춥니다. 재설치·재로그인하면 대개 풀리지만 다른 사람에게 또 나오고, 매번 재설치로 넘기면 원인을 끝내 모릅니다.
왜 그런가요재설치와 재인증은 단말 상태를 지우는 조치라 원인을 가릴 기록도 함께 사라집니다. 진단 자료는 증상이 보이는 그 순간의 단말에서만 의미가 있고, 나중에 뽑으면 정상 상태만 찍힙니다.
이렇게 합니다고치기 전에 증상이 남은 단말에서 세 가지를 받습니다.① 화면 전체 캡처: 창만 찍으면 안 됩니다. 작업 표시줄 시계가 찍혀야 현지 시각과 시간대가 확정되고, 따로 적어 달라고 하면 빠지기 쉽습니다. ② 연결 경로 확인: 접속 대상 몇 곳에 응답 확인과 경로 추적을 받습니다. 운영체제마다 명령이 다르니 복사해 붙일 한 덩어리로 주면 회수율이 훨씬 높습니다. ③ 진단 번들: 증상 직후 단말에서 수집합니다. 로그 기록을 미리 켜 두면 내용이 늘어납니다. 관리자가 원격으로 뽑은 자료는 받아들여지지 않을 수 있습니다. 원격 진단은 우리 쪽 1차 판단용이고, 공식 분석 자료는 사용자 단말에서 직접 나와야 합니다.
여기서 해 보기 — 오류 화면의 문구가 숫자보다 많은 것을 알려 줍니다. 진행률 숫자와 연결 단계를 잇는 공식 대응표는 없지만, 화면의 이름으로 어느 계층에서 실패했는지 가를 수 있습니다. 「Zero Trust」 탭의 연결 단계 설명에 그 대응이 있습니다.
이렇게 보입니다접근 정책에 다중 인증 관련 조건을 넣고 조직 설정에서 인증 앱도 허용했는데, 로그인하면 추가 인증 코드를 묻지 않고 들어가집니다.
왜 그런가요두 가지가 겹쳐 있습니다. ① 「인증 수단」 조건은 요구가 아니라 검사입니다. 연동한 신원 공급자가 「다중 인증을 거쳤다」고 보낸 값을 확인할 뿐이라, 값이 안 오면 조건이 충족되지 않을 뿐 Cloudflare 가 대신 묻지 않습니다. ② 떨어진 요청은 다른 정책으로 통과합니다. 같은 애플리케이션에 더 넓은 허용 정책이 있으면 그리로 들어와 아무도 추가 인증을 만나지 않습니다. 게다가 예전 방식 정책의 편집 화면에는 추가 인증을 요구하는 항목이 없어 「설정했다」고 믿게 됩니다.
이렇게 합니다① 재사용 가능한 정책을 새로 만들어 대상 애플리케이션에 연결합니다. 예전 방식 정책에는 필요한 항목이 없습니다. ② 그 정책에서 다중 인증을 «요구»로 켭니다. 수단은 인증 앱으로, 매 로그인마다 묻게 하려면 그 옵션도 켭니다. ③ 신원 공급자 값을 검사하는 조건은 새 정책에 넣지 않습니다. 이번 오해를 만든 설정입니다. ④ 넓은 허용 정책으로 새는 경로를 정리합니다. 빼먹으면 결과가 그대로이니, 정책 시험 도구로 대상 사용자가 어느 정책으로 통과하는지 먼저 봅니다. ⑤ 1차 로그인 수단은 따로 정합니다. 추가 인증을 켜도 기존 로그인 방식은 바뀌지 않습니다.
여기서 해 보기 — 바꾸기 전후에 정책 시험 도구에 같은 사용자를 넣어 보십시오. 바꾸기 전에는 엄격한 정책을 충족하지 않는데도 허용이 나오고, 그 한 줄이 새는 경로의 증거입니다. 실제 로그인으로 확인하기 전에는 설정했다고 적지 마십시오.
이렇게 보입니다전용 출구 IP(Dedicated Egress IP)를 받아 오리진과 협력사 허용 목록에 등록했는데, 어느 순간 접속이 막히거나 상대 로그에 등록하지 않은 IP가 찍힙니다. 지역 기반 차단을 쓰는 곳에서는 다른 나라 트래픽으로 보입니다.
왜 그런가요전용 출구 IP 는 주(primary)·예비(secondary) 두 위치에 배정되고, 주 위치에 문제가 생기면 예비 IP 로 나갑니다. 예비 위치는 다른 나라일 수 있습니다. 「서울」 한 곳만 골라도 결과는 한 쌍으로 오고, 확인하지 않으면 평소엔 국내·장애 땐 해외 IP 인 줄 모르고 씁니다. 그래서 두 가지가 조용히 깨집니다. ① 허용 목록: 예비 IP 가 없으면 페일오버가 곧 차단이고, 하필 장애 때 드러납니다. ② 출발지 나라 고정 전제: 경로 분리 목적(예: 취급 정보가 다른 환경을 다른 출구로)으로 쓸 때 예비가 해외면 장애 때 전제가 무너지고, 「국내 출구」를 전제로 쓴 내부 기준·규제 검토와 실제가 갈라집니다.
이렇게 합니다① 배정 결과를 «쌍»으로 적습니다. 발급 통지에 IP 값이 빠지는 경우가 있으니 IP 값·각 IP 의 위치·IPv6 배정 여부를 문서에 남기십시오. 값이 없으면 등록도 검증도 못 합니다. ② 허용 목록에 주·예비를 모두 넣습니다. 오리진이든 협력사든 같습니다. ③ 지역 기반 차단 경로를 점검합니다. 예비 위치의 나라가 차단 대상이면 페일오버가 곧 차단입니다. ④ 나라 단위 고정 요구에는 배정된 쌍을 확인한 뒤에 답합니다. 보장 여부는 배정 결과에 달렸습니다. ⑤ 추가 발급 때도 반복합니다. 추가분이 같은 위치 쌍으로 온다는 보장은 없습니다.
여기서 해 보기 — 배정 통지에 위치가 몇 개 적혔는지 보십시오. 하나뿐이면 나머지 하나를 묻지 않은 것입니다. 상대 허용 목록의 IP 개수와 우리가 받은 개수가 다르면 페일오버 때 끊깁니다.
이렇게 보입니다로그인에 인증 앱을 요구하도록 켜자 「등록된 인증 수단이 없습니다」가 뜹니다. 등록 버튼을 누르면 로그인 화면으로 돌아가고, 다시 들어가면 「이 계정은 접근 권한이 없습니다」로 막힙니다. 로그인 연동이 깨진 것처럼 보입니다.
왜 그런가요인증 앱을 등록하는 곳과 요구하는 곳이 다릅니다. 등록은 별도 포털에서 하는데 그 포털에도 접근 정책이 걸려야 들어갑니다. 허용 정책이 없으면 등록하려면 들어가야 하고, 들어가려면 등록돼 있어야 하는 잠김이 생깁니다. 로그인 연동은 멀쩡하고 막힌 것은 등록 포털입니다.
이렇게 합니다추가 인증은 두 단계입니다. ① 로그인에 인증 앱을 요구하는 정책과 ② 등록 포털 허용 정책을 같이 겁니다. 정책은 만들기만 해서는 안 되고 포털 정책 목록에 연결해야 동작합니다. 등록 포털에는 추가 인증을 걸지 마십시오. 아직 수단이 없는 사람이 들어갈 곳입니다. 포털 세션 시간을 「즉시 만료」로 두지 마십시오. 로그인 직후 세션이 끊겨 등록이 멈춥니다.
여기서 해 보기 — 등록 포털에서 로그인 화면이 한 번 더 뜨는 것은 별도 포털 인증이라 정상입니다. 「권한 없음」이면 정책이 연결되지 않은 것이고, 로그인 화면이 반복되면 세션 시간부터 보십시오.
IT 담당자가 요청한 WARP 진단 묶음은 WARP 앱이 평소 남기는 기록(로그)을 zip(압축 파일) 하나로 모은 것입니다. 명령 한 줄이면 바탕화면에 생기고, 보통 몇 분이면 끝납니다.
연결이 안 되는 상태여도 진단 묶음은 만들 수 있습니다. 앱을 깔고 아직 한 번도 로그인하지 않았다면「처음 한 번 — 로그인」부터 보십시오.
WARP 앱(Cloudflare One Client)이 없는 PC 에서는 만들 수 없습니다. 회사 PC 라면 대부분 IT 담당자가 미리 깔아 두었습니다. 있는지 모르겠다면 그냥 따라 해 보십시오. 없으면 명령을 입력했을 때 이렇게 나옵니다.
Windows — 'warp-diag' is not recognized as an internal or external command (한국어 Windows: 「내부 또는 외부 명령 … 이 아닙니다」)macOS — zsh: command not found: warp-diagLinux — warp-diag: command not found 또는 비슷한 문구
방금 설치했다면 명령 창을 닫고 새로 연 뒤 다시 입력하십시오. 그래도 같으면 IT 담당자에게 설치를 요청하십시오. 직접 설치해도 되는 Linux PC 라면 「Linux 에 설치하기」를 보십시오.
이 안내의 그림은 2026-09-23 실습용 PC(Windows · macOS · Linux)에서 직접 따라 하며 찍었습니다.
문제가 생긴 직후에 만듭니다
WARP 앱 안의 기록은 오래 보관되지 않습니다. 문제가 난 직후에 진단 묶음을 만들어야 그때의 기록이 들어갑니다.
문제가 생긴 시각(예: 오후 2시 5분)과 그때 있던 곳(사무실 · 집 · 휴대폰 핫스팟), 그리고 무엇이 안 됐는지를 메모해 두십시오. 마지막 단계에서 함께 보냅니다.
시계 옆의 위쪽 화살표(^)를 누릅니다
화면 오른쪽 아래, 작업 표시줄의 시계 왼쪽에 있습니다.
구름 아이콘이 이미 시계 옆에 보이면 이 단계는 건너뛰고 그 아이콘을 누릅니다.
🔍 누르면 크게 보입니다
작은 창의 회색 구름 아이콘을 누릅니다
회색 구름이 WARP 앱(Cloudflare One Client) 아이콘입니다. 회색은 연결이 꺼져 있다는 뜻입니다.
누른 뒤 사용 방식을 묻는 창 대신 Connected 나 Disconnected 화면이 보이면 이미 로그인된 것입니다. 「진단 묶음 받기」로 가십시오.
🔍 누르면 크게 보입니다
오른쪽 Cloudflare One Client 칸의 Continue 를 누릅니다
왼쪽 Private browsing 은 개인용(1.1.1.1)이라 회사 로그인이 되지 않습니다. 누르지 마십시오.
🔍 누르면 크게 보입니다
회사 팀 이름을 넣고 파란 Continue 를 누릅니다
팀 이름은 IT 담당자가 알려 주는, 영어·숫자로 된 짧은 이름입니다(예: mycompany). 주소 전체가 아니라 그 이름만 넣습니다.
그림의 square-king-ae7a 는 이 안내를 만든 팀의 이름입니다. 그대로 넣지 마십시오.
🔍 누르면 크게 보입니다
브라우저가 열리면 회사 이메일을 넣습니다
평소 쓰는 브라우저(Edge · Chrome 등)가 저절로 열립니다. Email 칸에 회사 이메일을 넣고 Send login code 를 누릅니다.
회사에 따라 이 화면 대신 회사 로그인 화면(Microsoft · Google 등)이 나옵니다. 그때는 평소 쓰는 회사 계정으로 로그인하면 됩니다.
그림 속 이메일은 예시 주소입니다.
🔍 누르면 크게 보입니다
메일로 온 6자리 코드를 넣고 Verify 를 누릅니다
보낸 사람 Cloudflare, 제목 「Cloudflare Access login code for …」 메일에 있습니다. 코드는 10분 안에 써야 합니다. 메일이 안 보이면 스팸함도 보십시오.
10분이 넘도록 메일이 오지 않으면 IT 담당자에게 알리십시오.
그림 속 이메일은 예시 주소입니다.
🔍 누르면 크게 보입니다
앱을 열지 묻는 창에서 여는 쪽 버튼을 누릅니다
Edge 는 This site is trying to open Cloudflare One Client. 창의 Open(열기)입니다. 버튼 이름이 Open · 열기 · Allow · 허용인 쪽을 누르고, Cancel · 취소는 누르지 않습니다. 버튼 색은 브라우저마다 다릅니다.
아래 체크 칸은 건드리지 않아도 됩니다. 창을 놓쳤다면 뒤 화면의 파란 Open in Cloudflare One Client 버튼을 누르면 같습니다.
🔍 누르면 크게 보입니다
Disconnected 가 보이면 파란 Connect 를 누릅니다
로그인만으로는 연결이 꺼져 있을 수 있습니다. Connect 를 한 번 누릅니다.
🔍 누르면 크게 보입니다
주황 구름과 Connected 가 보이면 로그인은 끝났습니다
창 오른쪽 위 X 로 닫아도 연결은 그대로 유지됩니다. 다음 ▶ 을 누르면 진단 묶음 받기로 넘어갑니다.
1~2분이 지나도 Connected 가 되지 않으면 그 상태 그대로 진단 묶음을 만드십시오. 그때 만든 묶음이 가장 쓸모 있습니다.
🔍 누르면 크게 보입니다
검색 칸에 cmd 를 넣고 명령 프롬프트를 엽니다
작업 표시줄의 검색(Search) 칸을 누르거나 키보드의 ⊞ Windows 키를 누른 뒤 cmd 를 입력합니다. 맨 위에 나온 명령 프롬프트(영문 Windows: Command Prompt)를 누릅니다. 오른쪽의 관리자 권한으로 실행(Run as administrator)은 누르지 않아도 됩니다.
글자가 한글로 찍히면 한/영 키를 눌러 영문으로 바꾼 뒤 다시 입력합니다.
🔍 누르면 크게 보입니다
검은 창에 이 글자를 입력하고 Enter 를 누릅니다
warp-diag
앞의 …> 까지는 원래 나와 있는 글자입니다. 그 뒤에 warp-diag 만 입력합니다. 복사를 눌렀다면 검은 창을 한 번 누르고 Ctrl+V 로 붙여 넣습니다.
「내부 또는 외부 명령이 아닙니다」가 나오면 창을 닫고 새로 연 뒤 다시 해 보십시오. 그래도 같으면 WARP 가 없는 것입니다 — IT 담당자에게 알리십시오.
🔍 누르면 크게 보입니다
Debugging information stored in 줄이 나올 때까지 기다립니다
1~2분쯤 걸릴 수 있습니다(실습 PC 는 35초). 화면에 this may take a couple minutes 가 보여도 정상입니다. 그동안 창을 닫지 마십시오.
이 줄 끝이 .zip 이면 성공입니다. 그 아래에 입력 줄(끝이 >)이 다시 나오면 끝난 것입니다.
중간에 영어 경고 줄이 섞여 나와도 괜찮습니다. .zip 줄 없이 오류로 끝나면 ⊞+Shift+S 로 화면을 잘라 낸 뒤 메일 본문에 Ctrl+V 로 붙여 IT 담당자에게 보내십시오.
🔍 누르면 크게 보입니다
바탕화면에 warp-debugging-info 파일이 생겼습니다
검은 창 오른쪽 위의 —(최소화)를 누르면 바탕화면이 보입니다. 이제 검은 창은 닫아도 됩니다.
warp-debugging-info-날짜-시각 이름의 파일이 진단 묶음입니다. Windows 는 끝의 .zip 을 숨겨서 보여 줍니다.
반디집 · 알집이 깔린 PC 는 아이콘 모양이 다릅니다. 이름으로 찾으십시오. 바탕화면에 안 보이면 ⊞+E 로 파일 탐색기를 열고 왼쪽 바탕 화면을 누릅니다. 그래도 없으면 3단계 Debugging information stored in 줄 끝의 경로가 파일 위치입니다. 그 줄을 캡처해 IT 담당자에게 보내도 됩니다.
🔍 누르면 크게 보입니다
이 파일을 그대로 IT 담당자에게 보냅니다
✓ 여기까지 하면 끝입니다. IT 담당자의 답을 기다리십시오.
바탕화면의 warp-debugging-info-… 파일을 풀지 말고 그대로, IT 담당자가 알려 준 방법(메일 첨부 등)으로 보냅니다. 메일 쓰기 창으로 끌어다 놓으면 첨부됩니다.
메일 본문에는 이렇게 적으면 됩니다.
WARP 진단 묶음 보냅니다. 문제 시각: 오전/오후 _시 _분 / 있던 곳: 사무실·집·휴대폰 핫스팟 / 증상: (예: 사내 사이트가 안 열림)
이 파일에는 PC 와 회사 네트워크 정보가 들어 있습니다. 게시판 · 단체 대화방에는 올리지 마십시오.
🔍 누르면 크게 보입니다 · 이 파일을 보냅니다
메뉴 막대의 구름 아이콘을 누릅니다
화면 오른쪽 위, 시계 왼쪽 아이콘 줄에 있습니다. Mac 에서는 앱 이름이 Cloudflare WARP 로 보이고, 연결이 꺼져 있으면 빗금 그어진 구름으로 보입니다.
눌렀을 때 Connected 나 Disconnected 화면이 보이면 이미 로그인된 것입니다. 「진단 묶음 받기」로 가십시오.
아이콘이 안 보이면(메뉴 막대가 꽉 찬 경우 등) ⌘ command+스페이스 로 「Cloudflare WARP」를 찾아 return(↩) 키를 누릅니다. 같은 창이 열립니다. 알림을 보내도 되는지 묻는 창이 뜨면 허용하거나 닫아도 됩니다.
🔍 누르면 크게 보입니다
오른쪽 Cloudflare One Client 칸의 Continue 를 누릅니다
왼쪽 Private browsing 은 개인용(1.1.1.1)이라 회사 로그인이 되지 않습니다. 누르지 마십시오.
🔍 누르면 크게 보입니다
회사 팀 이름을 넣고 파란 Continue 를 누릅니다
팀 이름은 IT 담당자가 알려 주는, 영어·숫자로 된 짧은 이름입니다(예: mycompany).
그림의 square-king-ae7a 는 이 안내를 만든 팀의 이름입니다. 그대로 넣지 마십시오.
🔍 누르면 크게 보입니다
브라우저가 열리면 회사 이메일을 넣습니다
평소 쓰는 브라우저(Safari · Chrome 등)가 저절로 열립니다. Email 칸에 회사 이메일을 넣고 Send login code 를 누릅니다.
회사에 따라 이 화면 대신 회사 로그인 화면(Microsoft · Google 등)이 나옵니다. 그때는 평소 쓰는 회사 계정으로 로그인하면 됩니다.
그림 속 이메일은 예시 주소입니다.
🔍 누르면 크게 보입니다
메일로 온 6자리 코드를 넣고 Verify 를 누릅니다
보낸 사람 Cloudflare, 제목 「Cloudflare Access login code for …」 메일에 있습니다. 코드는 10분 안에 써야 합니다. 메일이 안 보이면 스팸함도 보십시오.
10분이 넘도록 메일이 오지 않으면 IT 담당자에게 알리십시오.
그림 속 이메일은 예시 주소입니다.
🔍 누르면 크게 보입니다
앱을 열지 묻는 창에서 여는 쪽 버튼을 누릅니다
Safari 는 Do you want to allow this website to open “Cloudflare WARP”? 창의 Allow(허용)입니다. 버튼 이름이 Open · 열기 · Allow · 허용인 쪽을 누르고, Cancel · 취소는 누르지 않습니다.
창을 놓쳤다면 뒤 화면의 파란 Open in Cloudflare One Client 버튼을 누르면 같습니다.
🔍 누르면 크게 보입니다
Disconnected 가 보이면 파란 Connect 를 누릅니다
로그인만으로는 연결이 꺼져 있을 수 있습니다. Connect 를 한 번 누릅니다.
🔍 누르면 크게 보입니다
Connected 가 보이면 로그인은 끝났습니다
뒤에 남은 브라우저 창은 닫아도 됩니다. 다음 ▶ 을 누르면 진단 묶음 받기로 넘어갑니다.
1~2분이 지나도 Connected 가 되지 않으면 그 상태 그대로 진단 묶음을 만드십시오. 그때 만든 묶음이 가장 쓸모 있습니다.
🔍 누르면 크게 보입니다
⌘ command+스페이스 로 Terminal 을 엽니다
화면 가운데 검색창(Spotlight)에 Terminal 을 입력하고 return(↩) 키를 누릅니다. 한국어 macOS 에서는 결과가 「터미널」로 보입니다.
글자가 한글로 찍히면 한/영(또는 caps lock) 키로 영문으로 바꾼 뒤 다시 입력합니다. 검색창 대신 글자 입력 방식만 바뀌면, 메뉴 막대 오른쪽 위의 돋보기를 눌러 같은 검색창을 엽니다.
🔍 누르면 크게 보입니다
이 글자를 입력하고 return(↩) 을 누릅니다
warp-diag
% 뒤에 이 글자만 입력합니다. 앞의 이름과 % 는 원래 나와 있는 글자입니다. 복사를 눌렀다면 Terminal 창을 한 번 누르고 ⌘+V 로 붙여 넣습니다.
zsh: command not found: warp-diag 가 나오면 Terminal 을 닫고 새로 연 뒤 다시 해 보십시오. 그래도 같으면 WARP 가 없는 것입니다 — IT 담당자에게 알리십시오.
🔍 누르면 크게 보입니다
Debugging information stored in 줄이 나올 때까지 기다립니다
1~2분쯤 걸릴 수 있습니다(실습 PC 는 13초). 화면에 this may take a couple minutes 가 보여도 정상입니다. 그동안 창을 닫지 마십시오.
이 줄 끝이 .zip 이면 성공입니다. 그 아래에 % 줄이 다시 나오면 끝난 것입니다.
“Terminal” would like to access files in your Desktop folder(“터미널”이 데스크탑 폴더에 접근하려고 합니다) 창이 뜨면 Allow(허용)를 누릅니다(옛 macOS 는 OK). Don’t Allow(허용 안 함)는 누르지 마십시오.
중간에 영어 경고 줄이 섞여 나와도 괜찮습니다. .zip 줄 없이 오류로 끝나면 ⌘+Shift+3 을 누르십시오. 바탕화면에 스크린샷 파일이 생기니 그 파일을 메일에 첨부해 IT 담당자에게 보냅니다.
🔍 누르면 크게 보입니다
바탕화면에 warp-debugging-info 파일이 생겼습니다
Terminal 창 왼쪽 위의 노란 버튼을 누르면 창이 내려가고 바탕화면이 보입니다. 파일은 보통 오른쪽 위에 있습니다(아이콘이 많으면 그 옆). 이름이 길어서 가운데가 「…」로 줄어 보입니다.
다른 창에 가려 안 보이면 다음 단계처럼 Finder 의 Desktop(데스크탑)에서 찾으십시오.
🔍 누르면 크게 보입니다
Finder 의 Desktop 에서 전체 이름이 보입니다
Dock(화면 아래 앱 막대)의 Finder(파일 보는 앱)를 열고 왼쪽 Desktop(한국어 macOS: 데스크탑)을 누릅니다. warp-debugging-info-날짜-시각.zip 이 보낼 파일입니다.
🔍 누르면 크게 보입니다
이 파일을 그대로 IT 담당자에게 보냅니다
✓ 여기까지 하면 끝입니다. IT 담당자의 답을 기다리십시오.
바탕화면의 warp-debugging-info-….zip 을 풀지 말고 그대로, IT 담당자가 알려 준 방법(메일 첨부 등)으로 보냅니다. 두 번 누르면 압축이 풀리니, 메일 쓰기 창으로 끌어다 놓아 첨부하십시오.
메일 본문에는 이렇게 적으면 됩니다.
WARP 진단 묶음 보냅니다. 문제 시각: 오전/오후 _시 _분 / 있던 곳: 사무실·집·휴대폰 핫스팟 / 증상: (예: 사내 사이트가 안 열림)
이 파일에는 PC 와 회사 네트워크 정보가 들어 있습니다. 게시판 · 단체 대화방에는 올리지 마십시오.
🔍 누르면 크게 보입니다 · 이 파일을 보냅니다
Ctrl+Alt+T 로 터미널을 엽니다
Ubuntu 기본 화면에서 이 키를 함께 누르면 터미널이 열립니다. 그림의 PC 처럼 아래 막대에 $_ 아이콘이 있다면 그것을 눌러도 됩니다.
이미 로그인했는지 모르겠다면 warp-cli status 를 입력해 보십시오. Registration Missing 이 아니면 로그인은 되어 있습니다 — 「진단 묶음 받기」로 가십시오.
🔍 누르면 크게 보입니다
이 명령 뒤에 팀 이름을 붙여 입력하고 Enter 를 누릅니다
warp-cli registration new 팀이름
new 뒤에 한 칸 띄우고 팀 이름을 씁니다. 팀 이름은 IT 담당자가 알려 주는, 영어·숫자로 된 짧은 이름입니다.
그림의 square-king-ae7a 는 이 안내를 만든 팀의 이름입니다. 그대로 넣지 마십시오.
🔍 누르면 크게 보입니다
처음 한 번 묻는 약관에 y 를 입력하고 Enter 를 누릅니다
회사 관리자가 방문한 웹사이트와 시각을 볼 수 있다는 안내입니다.
🔍 누르면 크게 보입니다
브라우저가 저절로 열리면 회사 이메일을 넣습니다
Email 칸에 회사 이메일을 넣고 Send login code 를 누릅니다. 회사에 따라 회사 로그인 화면(Microsoft · Google 등)이 나옵니다.
브라우저가 열리지 않으면 IT 담당자에게 알리십시오.
그림 속 이메일은 예시 주소입니다.
🔍 누르면 크게 보입니다
메일로 온 6자리 코드를 넣고 Verify 를 누릅니다
보낸 사람 Cloudflare, 제목 「Cloudflare Access login code for …」 메일에 있습니다. 코드는 10분 안에 써야 합니다. 메일이 안 보이면 스팸함도 보십시오.
10분이 넘도록 메일이 오지 않으면 IT 담당자에게 알리십시오.
그림 속 이메일은 예시 주소입니다.
🔍 누르면 크게 보입니다
링크를 열지 묻는 창에서 여는 쪽 버튼을 누릅니다
Firefox 는 Open Link 입니다. 버튼 이름이 Open · 열기인 쪽을 누르고, Cancel · 취소는 누르지 않습니다.
창이 안 보이면 뒤 화면의 파란 Open in Cloudflare One Client 버튼을 누릅니다.
🔍 누르면 크게 보입니다
브라우저를 닫고 터미널로 돌아옵니다
마지막 줄에 Successfully enrolled team, but no further action required. 가 보입니다. 뒤의 no further action required 는 신경 쓰지 않아도 됩니다. 아직 연결 전이니 다음 단계로 갑니다.
🔍 누르면 크게 보입니다
연결 명령을 넣고 상태를 확인합니다
로그인만으로는 연결되지 않습니다. 두 줄을 차례로 입력합니다.
warp-cli connect
warp-cli status
터미널에서는 Ctrl+Shift+V 로 붙여 넣습니다.
Status update: Connected 가 보이면 끝입니다. 1~2분이 지나도 Connected 가 되지 않으면 그 상태 그대로 진단 묶음을 만드십시오.
🔍 누르면 크게 보입니다
Ctrl+Alt+T 로 터미널을 엽니다
Ubuntu 기본 화면에서 이 키를 함께 누르면 터미널이 열립니다. 그림의 PC 처럼 아래 막대에 $_ 아이콘이 있다면 그것을 눌러도 됩니다.
🔍 누르면 크게 보입니다
이 글자를 입력하고 Enter 를 누릅니다
warp-diag
$ 뒤에 이 글자만 입력합니다. 앞에 sudo 를 붙이지 않습니다. 복사를 눌렀다면 터미널에서 Ctrl+Shift+V 로 붙여 넣습니다.
warp-diag: command not found 가 나오면 WARP 가 없는 것입니다 — IT 담당자에게 알리십시오.
🔍 누르면 크게 보입니다
Debugging information stored in 줄이 나올 때까지 기다립니다
1~2분쯤 걸릴 수 있습니다(실습 PC 는 20초도 걸리지 않았습니다). 화면에 this may take a couple minutes 가 보여도 정상입니다. 그동안 창을 닫지 마십시오.
이 줄 끝이 .zip 이면 성공입니다. 그 아래에 $ 줄이 다시 나오면 끝난 것입니다. 이 줄에 적힌 경로가 파일 위치입니다.
중간에 영어 경고 줄이 섞여 나와도 괜찮습니다. .zip 줄 없이 오류로 끝나면 마지막 몇 줄을 마우스로 끌어 선택하고 Ctrl+Shift+C 로 복사해 메일에 붙여 IT 담당자에게 보내십시오.
🔍 누르면 크게 보입니다
파일 관리자의 Desktop 폴더에 있습니다
파일 관리자(Ubuntu 는 Dock 의 파일, 그림의 PC 는 아래 막대의 집 모양 아이콘)를 열고 왼쪽 Desktop(한국어: 바탕화면)을 누르면 warp-debugging-info-날짜-시각.zip 이 보입니다.
앞 단계 경로가 /home/이름/warp-debugging-info-… 처럼 Desktop 없이 끝나면, 파일 관리자의 홈 폴더에 있습니다.
🔍 누르면 크게 보입니다
이 파일을 그대로 IT 담당자에게 보냅니다
✓ 여기까지 하면 끝입니다. IT 담당자의 답을 기다리십시오.
warp-debugging-info-….zip 을 풀지 말고 그대로, IT 담당자가 알려 준 방법으로 보냅니다.
메일 본문에는 이렇게 적으면 됩니다.
WARP 진단 묶음 보냅니다. 문제 시각: 오전/오후 _시 _분 / 있던 곳: 사무실·집·휴대폰 핫스팟 / 증상: (예: 사내 사이트가 안 열림)
이 파일에는 PC 와 회사 네트워크 정보가 들어 있습니다. 게시판 · 단체 대화방에는 올리지 마십시오.
🔍 누르면 크게 보입니다 · 이 파일을 보냅니다
PC 앞에 갈 수 없을 때 — IT 담당자가 원격으로 받습니다
해외 지점 · 현장 인원처럼 직접 받기 어려울 때, IT 담당자(Zero Trust 관리자)가 대시보드에서 대신 받습니다. 사용자는 문제 난 시각과 있던 곳만 알려 주면 됩니다. 이 방법도 그 PC 에 WARP 가 깔려 있어야 합니다.
조건 — WARP 2024.12.492.0 이상의 Windows · macOS · Linux, 조직에 등록된 PC, 그리고 그 PC 의 WARP 가 연결되어 있는 동안. WARP 가 DNS 만 쓰는 모드(DNS only)면 안 됩니다. 휴대폰(iOS · Android)도 안 됩니다.
WARP 가 끊긴 PC 에서는 원격 수집이 실패합니다. 그때는 이 페이지의 Windows · macOS · Linux 안내를 사용자에게 보내 직접 받게 하십시오.
DEX › Remote captures 에서 요청합니다
Cloudflare One › DEX(디지털 경험 모니터링 — 사용자 PC 상태를 보는 화면) › Remote captures(화면에 따라 Insights › Digital experience 아래)
PC 를 고릅니다 — 한 번에 최대 10대
Device diagnostic logs 를 고르고 Run diagnostics
그 PC 의 WARP 가 끊겨 있으면 10분 뒤 시간 초과로 끝납니다. Test all routes 는 켜지 않아도 됩니다.
끝난 줄에서 내려받습니다
Status 가 Success 인 줄의 ⋯ › Download 로 zip 을 받습니다. 지난 96시간의 기록이 들어 있습니다. ⋯ › View Device Diag 를 누르면 주요 이상 징후를 먼저 추려 줍니다(대시보드로 받은 기록에만 됩니다).
zip 안에서 먼저 볼 파일
daemon.log
WARP 가 한 일 전부 — 공식 문서가 「가장 유용한 디버그 로그」라고 부릅니다. 최신 기록은 파일 끝에 있습니다. Windows 탐색기는 확장자를 숨겨 daemon 으로 보입니다.
warp-status.txt
받은 순간의 연결 상태
warp-settings.txt
실제로 적용된 설정
connectivity.txt
주소 찾기(DNS) · 웹 연결 확인 — 트래픽이 WARP 를 타는지
date.txt
받은 시각 — 세계 표준시(UTC)라 한국 시각보다 9시간 느립니다
파일 끝의 시각이 문제가 난 시각을 덮는지 먼저 확인하십시오. 기록은 파일당 10MB · 24시간이 차면 새 파일로 넘어가고 4개까지만 남습니다 — 조용하면 며칠치, 오류가 잦으면 몇 시간치만 남습니다.
고객 현장과 실습 서버에서 WARP 를 쓰다 겪은 장애와 주요 기능을 세 OS 실습 PC 에서 직접 다시 해 본 기록입니다. 판정 색은 왼쪽 목록의 묶음 머리를 보십시오.
연결이 26% · Performing Happy Eyeballs 에서 멈춥니다
회사 방화벽이 바깥 UDP 를 막은 곳에서, 터널 프로토콜이 WireGuard 이거나 비어 있는(미지정) 프로필이면 그대로 멈췄습니다. 같은 차단에서도 MASQUE 는 몇 초 만에 TCP 443 으로 돌아 붙었습니다.
Windows
부분 재현
미지정은 120초, WireGuard 는 90초 동안(차단을 풀 때까지) 26% 에서 멈춤. MASQUE 는 4.0초에 TCP 443 으로 연결
macOS
부분 재현
WireGuard 는 5분, 미지정은 90초 동안(차단을 풀 때까지) 26% 에서 멈춤. MASQUE 는 6.5초에 TCP 443 으로 연결
Linux
부분 재현
GUI 는 26% 가 아니라 20% · Performing Happy Eyeballs 와 Connection blocked 를 번갈아 보였고, 상태는 Unable — Failed to perform happy eyeballs 에서 계속 돎. MASQUE 는 3.3초에 연결
단말 프로필의 Device tunnel protocol 을 MASQUE 로 명시합니다.
방화벽에서 위 UDP 포트를 엽니다.
이번 실습의 Windows·macOS 에서는 프로필을 바꾸고 3~10분 기다려도 반영되지 않아, 한 번 끊었다 다시 연결해야 반영됐습니다. Linux 는 약 1분 만에 저절로 반영됐고, 다른 실습에서는 Windows 도 1분 안에 저절로 반영됐습니다(「Split Tunnel 반대로 적용」 항목).
한 고객사의 해외 지점 PC 몇 대가 「연결 중 26% · Happy Eyeballs」에서 넘어가지 않았습니다. 같은 망의 다른 PC 는 정상이었고, 그 조직의 기본 프로필은 터널 프로토콜이 비어 있었습니다.
세 OS 실습 PC 의 방화벽에서 바깥 UDP(443 · 500 · 1701 · 2408 · 4443 · 4500 · 8095 · 8443)를 막았습니다.
프로필의 터널 프로토콜을 MASQUE · WireGuard · 미지정 세 가지로 바꿔 가며 연결했습니다.
미지정으로 두면 이 클라이언트(2026.7)는 WireGuard 로 붙었습니다. WireGuard 는 UDP 만 쓰므로 막히면 갈 길이 없습니다.
MASQUE 는 UDP 443 이 막히면 UDP 500 으로, UDP 가 다 막히면 TCP 443(HTTP/2)으로 옮겨 붙었습니다.
프로토콜을 비워 둔(미지정) 프로필로 바깥 UDP 를 막자 Connecting... 26% 와 Performing Happy Eyeballs 에서 멈췄습니다. 이 화면은 연결을 시작하고 84초 뒤이며, 120초 내내 붙지 않았습니다.
MDM 파일에 끝점을 적어 둔 단말은 그 주소로만 붙고, 값이 틀리면 18% · Performing Happy Eyeballs 에서 멈춥니다
MDM 파일의 override_warp_endpoint(클라이언트가 터널을 여는 Cloudflare 쪽 주소, 곧 끝점을 덮어쓰는 값)가 있으면 세 OS 모두 기본 끝점 대신 그 주소로만 붙었습니다. 닿지 않는 주소를 넣자 다른 주소로 돌아가지 않고 멈췄고, 그동안 인터넷도 끊겼습니다.
Windows
재현됨
162.159.197.1 로 바꾸자 2.6초에 그 주소로 연결. 192.0.2.1 이면 18% 에서 73초 멈춤, 웹 요청 실패
macOS
재현됨
162.159.197.1 로 2초에 연결. 192.0.2.1 이면 세 번 모두 18% 에서 멈추고 바깥 통신 끊김
Linux
재현됨
162.159.197.1 로 1초에 연결. 192.0.2.1 이면 약 3분 동안 Performing happy eyeballs to 192.0.2.1:443
끝점을 바꾸거나 되돌릴 때는 대시보드가 아니라 MDM 파일(또는 MDM 배포 도구)의 override_warp_endpoint 를 고치거나 지우고 서비스를 다시 켭니다. 원래 파일로 되돌리자 Windows 2.5초, macOS 1~2초 만에 기본 끝점으로 붙었고 Linux 도 돌아왔습니다(실습 PC 에서 확인).
값을 넣을 때는 방화벽 문서의 MASQUE 대역(162.159.197.0/24) 안 주소인지, 회사 방화벽이 그 주소의 UDP 443 을 여는지 먼저 봅니다(162.159.197.1:443 은 정상 연결 확인). 공식 문서상 값은 IP:포트 형식이어야 하고 틀리면 MDM 파일 전체를 읽지 못합니다(실습에서는 확인하지 않음).
「Happy Eyeballs 에서 멈춤」 문의가 오면 warp-cli settings 에 Override WARP endpoint 줄이 있는지부터 봅니다.
한 고객사가 해외 지점 단말의 끝점을 그 지역용 주소로 옮길 때, MDM(단말 일괄 관리 도구)으로 배포한 단말은 대시보드만 고쳐서는 바뀌지 않고 MDM 파일의 override_warp_endpoint 값을 따른다는 운영 결론이 나왔습니다. 이것이 실제로 그런지 세 OS 에서 확인했습니다.
세 OS 실습 PC 의 MDM 파일에 override_warp_endpoint 한 줄만 더하고 클라이언트 서비스를 다시 켰습니다. 인터넷이 끊겨도 돌아오도록, 몇 분 뒤 원래 파일로 되돌리는 예약 작업을 먼저 걸었습니다.
값은 먼저 Cloudflare 대역 안의 다른 주소(162.159.197.1:443), 다음에 아무도 응답하지 않는 문서용 주소(192.0.2.1:443)로 바꿨습니다.
대시보드 쪽 갈래(Global Acceleration)는 실습 조직이 무료 요금제라 프로필에 항목이 없어 재현하지 못했습니다.
MDM 값이 있으면 기본 끝점(162.159.197.2:443)과 프로필 설정보다 우선했습니다. 이때 warp-cli settings 에 (local policy) Override WARP endpoint: … 줄이 나타납니다.
닿지 않는 주소면 MASQUE 뒤의 보조 경로(HTTP/2)도 같은 주소로만 시도했고, 기본 끝점으로 돌아가지 않았습니다.
멈춘 동안 단말의 바깥 통신이 끊겼습니다(Windows 는 웹 요청 실패, macOS 는 세 번 모두 응답 없음).
화면 문구가 UDP 차단 장애(「26%에서 멈춤」 항목)와 같은 Performing Happy Eyeballs 라서 화면만으로는 둘을 구분할 수 없습니다. 이번에는 26% 가 아니라 18% 에서 멈췄습니다.
# 대역 안 다른 주소로 바꿨을 때(Windows 로그)
Primary protocol won race, upgrading connection protocol="masque" endpoint=162.159.197.1:443
# 닿지 않는 주소로 바꿨을 때(Linux)
$ warp-cli settings | grep "Override WARP endpoint"
(local policy) Override WARP endpoint: 192.0.2.1:443
$ warp-cli status
Status update: Connecting
Reason: Performing happy eyeballs to 192.0.2.1:443 and [::]:0
# 같은 때 macOS 로그 — MASQUE 도 HTTP/2 도 같은 주소로만
ERROR …{protocol="masque" pq=true}: warp_edge::happy_eyeballs: Happy eyeballs error AllChecksTimedOut
ERROR …{protocol="H2" pq=true}: warp_edge::happy_eyeballs: Happy eyeballs error AllChecksTimedOut
MDM 파일의 override_warp_endpoint 를 아무도 응답하지 않는 주소(192.0.2.1:443)로 바꾸고 서비스를 다시 켜자 Connecting... 18% · Performing Happy Eyeballs 에서 멈췄습니다. 되돌릴 때까지 73초 동안 넘어가지 못했고, 30초·70초 뒤 웹 요청도 실패했습니다.
집에서는 붙는데 회사 방화벽 안에서만 연결·등록이 안 됩니다
Windows 실습 PC 의 단말 방화벽을 «나가는 연결 기본 차단»으로 두면 도메인이 가리키는 IP 만 열어도, 공식 표의 IP·포트를 열어도 붙지 않았고 warp-svc.exe 프로그램 규칙이어야 붙었습니다(회사 경계 방화벽으로는 시험하지 않았습니다). 관리 API 주소가 막히거나 인증서가 바꿔 끼워지면 붙은 연결은 멀쩡해 보여도 다시 등록할 때 실패했고, 막힘을 풀어도 저절로 낫지 않았습니다.
Windows
재현됨
도메인 IP 만 열면 36.8초에 Connection blocked, 표의 IP·포트만 열면 19.2초에 DNS lookup failure. warp-svc.exe 프로그램 규칙이면 1.8초에 연결
macOS
부분 재현
팀 도메인을 막으면 로그인이 Safari Can't Connect to the Server 에서 멈춤. UDP 443 만 막으면 UDP 500 으로 1.73초에 연결. 프로그램 단위 허용은 macOS 기본 방화벽으로 만들 수 없어 못 함
Linux
재현됨
API 주소를 막거나 TLS 를 가로채도 붙은 연결은 유지. 등록을 지우면 다시 등록 못 하고, 풀어도 5분 넘게 그대로. WARP 서비스(warp-svc) 재시작 3초 뒤 연결
단말 방화벽(백신·EDR 방화벽 포함)이면 warp-svc.exe프로그램 규칙으로 엽니다(Windows 실습 PC 에서 1.8초 연결 확인). IP 로만 열어야 하면 표에 더해 DoH IP 162.159.36.1·162.159.46.1 TCP 443 도 엽니다(이 조합은 시험하지 않았습니다).
도메인이 아니라 공식 표의 IP 대역을 엽니다. 팀 도메인도 허용 목록에 넣습니다(macOS 에서 막힘을 풀자 바로 로그인 화면이 뜬 것을 확인).
API 주소 TCP 443 을 열고 SSL 검사(복호화)에서 뺍니다. 푼 뒤에도 등록이 안 되면 Linux 는 WARP 서비스(warp-svc) 재시작(3초, 두 번 확인), Windows 는 warp-cli registration delete 를 한 번 더(2.4초) 해서 다시 등록됐습니다. 남은 옛 등록 줄은 대시보드 Devices 에서 지웁니다.
Include 에 사내 대역을 넣을 때는 그 안의 DNS 서버·게이트웨이가 함께 터널로 들어가는지 먼저 봅니다(되돌리자 바로 정상이 된 것을 확인).
한 고객사의 해외 협력사 사무실에서 「집에서는 되는데 사내망에서만 안 된다」는 문의가 왔습니다. 회사 방화벽에 무엇을 열어야 하는지(허용 목록, SSL 검사 예외, 사내 대역과 경로 겹침)가 쟁점이었습니다.
Windows: 단말 방화벽을 «나가는 연결 기본 차단»으로 두고 ① 도메인이 가리키는 IP 만 ② 공식 표의 IP·포트만 ③ warp-svc.exe 프로그램 규칙, 세 가지로 열어 연결했습니다.
Windows·Linux: 관리 API 주소(api.devices.cloudflare.com = 162.159.137.105·138.105, 클라이언트가 등록·설정을 주고받는 곳) TCP 443 을 막은 채 등록을 지웠습니다. Linux 는 회사 프록시처럼 인증서를 바꿔 끼우는 TLS 가로채기(mitmproxy)도 했습니다.
macOS: hosts 파일로 팀 도메인(<팀>.cloudflareaccess.com)을 막고 로그인했습니다. Linux 는 Split Tunnel Include(적은 주소만 터널로 보냄)에 사내 DNS 서버 주소 한 줄(/32)을 넣었습니다.
모두 실습 PC 자체 방화벽·hosts·nftables 로 흉내 냈습니다. 회사 경계 방화벽 장비로는 시험하지 않았습니다.
「도메인만 허용」으로는 붙지 않았습니다. 클라이언트는 도메인을 풀지 않고 공식 표의 IP(터널 162.159.197.x)로 바로 붙습니다.
표의 IP·포트를 다 열어도, 단말 방화벽이 기본 차단이면 터널 안으로 보내는 DNS(DoH, DNS 를 HTTPS 로 묻는 것, 162.159.36.1) 연결까지 막혀 붙지 않았습니다. 공식 문서는 이 IP 를 «DNS only 모드에서만 필요»라고 적고 있습니다. 공식 지원 OS 가 아닌 Windows Server 2025 에서 본 결과입니다.
API 주소가 막혀도 이미 붙은 연결은 끊기지 않아 티가 나지 않습니다. 재설치·등록 삭제처럼 등록을 새로 만들 때 실패하고, 막힘을 풀어도 스스로 다시 등록하지 않았습니다. 서버에는 지운 등록 줄이 남기도 했습니다.
팀 도메인이 막히면 로그인이 브라우저에서 멈추고, 클라이언트 창은 원인을 알려 주지 않았습니다. Include 에 사내 DNS 서버를 넣으면 그 주소가 터널로 빨려 들어가 내부 이름 조회까지 끊겼습니다.
# Windows — 표의 IP·포트만 열었을 때: 터널 안 DoH 가 단말 방화벽에 막힘
INFO warp_connection::dns::proxying_dns: Putting DoH connection inside the tunnel
WARN dns_proxy::resolver::errors: DoH error encountered error=Io(Os { code: 10013, kind: PermissionDenied, … }) ip=162.159.36.1 error_source=Establishment
WARN connectivity_check: Unable to resolve connectivity.cloudflareclient.com via direct UDP lookup error=Dns(ResponseCode(ServFail))
# Linux — TLS 가로채기 중: API 요청이 인증서 오류
ERROR api_request{… request=delete_registration}: warp_api_client::client: Request failed, retrying in 2s err=Transient { … InvalidCertificate(UnknownIssuer) … }
# Linux — API 차단을 푼 뒤: 새 등록을 포기하고 스스로 복구하지 않음
INFO … request=new_registration}: … Request completed in 183662ms result=Err(ForceNoRetry(DetectedInvalidatedRegistration(ApiMismatch)))
Status update: Unable
Reason: Registration Missing due to: Does not exist in API
Windows 방화벽을 나가는 연결 기본 차단으로 두고 도메인이 가리키는 IP 만 열자, 36.8초 만에 Connection blocked 가 떴습니다. 오류 코드는 칸이 좁아 잘렸지만 CF_HAPPY_EYEBALLS_MITM_FAILURE 입니다.
회사 로그인 서버(IdP)에 닿지 못하면 첫 등록이 SAML 로그인 단계에서 멈춥니다
등록 전에는 IdP 로 가는 연결이 PC 의 실제 네트워크로만 나갑니다. 그래서 그 길의 443 이 막히면 브라우저 오류만 남고 클라이언트는 기다리기만 했습니다. WARP 가 붙은 뒤에는 같은 차단이 있어도 IdP 연결이 터널로 나가 통했습니다. 다만 SAML 로그인을 끝까지 해 보지는 못했습니다.
Windows
부분 재현
SAML 단계에서 ERR_NETWORK_ACCESS_DENIED 로 멈춤. 연결 뒤에는 같은 차단에도 IdP 443 이 CloudflareWARP 로 통함
Linux
부분 재현
약 4분 Connecting… 뒤 The connection has timed out. 연결 뒤에는 IdP 로 가는 길이 터널로 바뀌고 물리 NIC 차단 카운터는 0
첫 등록이 일어나는 네트워크에서 IdP 443 을 열어 둡니다. 공식 문서도 로그인 단계에 쓰는 IdP 와 <팀 이름>.cloudflareaccess.com 을 허용하라고 적고 있습니다.
열 수 없으면 One-time PIN 같은 다른 로그인 방법으로 먼저 등록합니다. 붙은 뒤에는 IdP 연결이 터널로 나갑니다. 연결까지는 확인했고 SAML 완주는 확인하지 못했습니다.
다른 로그인 방법을 열어 두면 그 경로로 로그인할 때는 AD FS 의 2단계 인증을 건너뛰게 됩니다.
고객처럼 IdP 가 사설 주소에 있으면 사설 경로를 터널로 준비하고 Split Tunnels 에서 그 주소를 터널로 보내야 합니다. 이 부분은 문서로만 확인했고 랩에서는 시험하지 않았습니다.
한 고객사의 PC 한 대에서 WARP 첫 등록(로그인)이 되지 않았습니다. 그 PC 가 사내 AD FS(Microsoft 의 사내 로그인 서버, IdP) 443 포트에 닿지 못한 것이 원인이었습니다. 그 IdP 는 사설 주소에 있었습니다.
Windows · Linux 실습 PC 를 등록이 없는 상태로 되돌렸습니다.
PC 의 물리 네트워크 카드에서만 랩 IdP(AD FS 를 흉내 낸 SAML 서버) 주소로 가는 TCP 443 을 막았습니다. 그다음 로그인 화면에서 SAML 버튼을 눌렀습니다.
One-time PIN(메일로 받는 일회용 코드)으로 따로 등록했습니다. 그 뒤 같은 차단을 다시 걸고 IdP 로 가는 연결이 어디로 나가는지 봤습니다.
막힌 채로는 SAML 단계에서 멈췄습니다. 사용자에게는 브라우저 오류만 보였습니다. Windows Edge 는 바로 ERR_NETWORK_ACCESS_DENIED 를 보였습니다. 응답 없이 버리는 방식으로 막은 Linux Firefox 는 약 4분 기다린 뒤 The connection has timed out 을 보였습니다.
그동안 클라이언트는 따로 오류를 띄우지 않았습니다. Linux 에서는 Awaiting external log in 에서 기다리기만 했습니다.
WARP 가 붙은 뒤에는 공인 주소에 있는 IdP 로 가는 연결이 CloudflareWARP 터널로 나갔습니다. 그래서 물리 카드 차단과 상관없이 443 이 열렸습니다(두 OS 에서 확인).
SAML 로그인 완주와 터널 안 재인증은 확인하지 못했습니다. 랩 IdP 에 실험용 계정이 없었고, 랩 사이트의 보안 규칙이 실습 PC 를 막았습니다.
$ ip route get 104.18.6.3 # 등록 전, ens5 에서 IdP 443 을 막은 채
… via 172.31.0.1 dev ens5
$ ip route get 104.18.6.3 # WARP 연결 뒤, 같은 차단을 다시 걸고
… dev CloudflareWARP table 65743 src 100.96.0.3
$ curl https://idp.nxlab.cflare.kr/saml/sso
http=403 local=2606:4700:cf1:1000::3 remote=2606:4700::6812:703 t=0.49s
# ens5 차단 규칙 카운터 packets 0 · 403 은 랩 사이트 보안 규칙의 응답
# Windows — WARP 연결 뒤 같은 차단 규칙을 다시 켜고
PS> Test-NetConnection idp.nxlab.cflare.kr -Port 443
TcpTestSucceeded=True · 인터페이스 CloudflareWARP
물리 네트워크 카드에서 IdP 443 을 막고 로그인 화면의 SAML 버튼을 눌렀습니다. Edge 가 IdP 주소(/saml/sso)에서 ERR_NETWORK_ACCESS_DENIED 를 보였고, 등록은 여기서 더 나아가지 않았습니다.
이메일 코드로 로그인하면 사내 AD FS 의 2단계 인증 없이 단말이 등록됩니다
등록 앱의 로그인 방법을 비워 두면 이메일 코드(One-time PIN)도 함께 열려, 메일함만 열 수 있으면 AD FS 를 거치지 않고 등록됐습니다. 등록 정책에 Authentication method = mfa 를 요구해도 Cloudflare 는 2단계를 따로 묻지 않았고, IdP 가 MFA 를 했다고 알려 준 로그인만 통과시켰습니다.
Linux
재현됨
이메일 코드만으로 AD FS 없이 등록돼 Connected. mfa 요구를 넣어도 TOTP 는 묻지 않고, IdP 가 보낸 amr 에 mfa 가 있을 때만 통과
AD FS 를 반드시 거치게 하려면 등록 앱의 로그인 방법에서 One-time PIN 을 빼고 AD FS 만 남깁니다(Device enrollment permissions → Login methods, API 로는 allowed_idps). 비워 두면 모든 수단이 열리는 것까지 확인했고, 빼 둔 상태는 시험하지 않았습니다. AD FS 에 없는 사용자(협력사 계정 등)가 있는지 먼저 봅니다.
One-time PIN 을 비상 등록 수단으로 남긴다면(「IdP 막혀 등록 멈춤」 항목) 그 경로로는 AD FS 의 2단계를 거치지 않는다는 점을 감안합니다.
Authentication method = mfa 요구는 이메일 코드 등록을 막고 OIDC IdP 의 MFA 를 확인하는 데는 효과가 있었습니다(실습 확인). Cloudflare 가 2단계를 직접 묻게 하는 설정은 아닙니다.
SAML IdP 라면 이 요구를 켜기 전에 Policy tester 와 시험 로그인으로 실제로 통과하는지 먼저 봅니다. 공식 문서는 Generic SAML 2.0 에서도 이 조건을 쓸 수 있다고 적고 있지만, 랩 SAML 로는 한 번도 통과하지 못했습니다.
한 고객사는 WARP 로그인에 2단계 인증(MFA)이 걸려 있다고 생각했습니다. 그런데 단말 등록 앱이 사내 AD FS(Microsoft 의 사내 로그인 서버, SAML 방식)와 One-time PIN(메일로 받는 일회용 코드)을 함께 허용하고 있어, 이메일 코드로 AD FS 를 건너뛰고 등록할 수 있었습니다. 등록 정책의 Authentication method = mfa 조건이 2단계를 직접 묻는지도 확인이 필요했습니다.
Linux 실습 PC 에서 등록 앱의 허용 IdP(로그인 방법)를 비워 둔 채 등록 로그인 화면을 열고, 이메일 코드로만 로그인했습니다.
등록 정책에 Require → Authentication method = mfa 를 넣고 이메일 코드, OIDC IdP(2단계를 거친 경우와 건너뛴 경우), AD FS 를 흉내 낸 랩 SAML IdP(2단계를 거침)로 다시 로그인했습니다.
IdP 갈래는 랩 IdP 가 실습 PC 의 접속을 막아서, 별도 PC(Windows 11)의 스크립트로 같은 로그인 흐름을 따라가 확인했습니다. 등록 링크는 클라이언트로 열지 않았습니다.
Access 의 Policy tester(정책 시험 도구)와 관리자 로그인 기록도 함께 봤습니다.
허용 IdP 를 비워 두면 계정에 연결된 로그인 수단이 모두 나왔습니다. 이메일 코드만 넣고 Open Link 를 누르자 단말이 Connected 가 됐고, AD FS 는 한 번도 거치지 않았습니다.
mfa 요구를 넣어도 Cloudflare 는 TOTP(인증 앱의 6자리 코드)를 묻지 않았습니다. IdP 가 보낸 amr(어떤 방법으로 인증했는지 알려 주는 값)에 mfa 가 있으면 Success!, 없으면 That account does not have access. 로 바로 거부했습니다.
이메일 코드 쪽은 막혔습니다. 로그인해 둔 브라우저는 리디렉션 반복에서 멈췄고, 처음 쓰는 주소로는 코드 메일이 4분 20초 동안 오지 않았습니다(요구를 빼면 13초 만에 옴). 이 막힘은 관리자 로그에 남지 않았습니다.
랩 SAML 은 2단계를 거쳐 MultiFactorAuthentication 을 보냈는데도 거부됐습니다. 요구를 빼면 같은 로그인이 통과했고, 그때 Access 가 본 신원에는 amr 이 없었습니다. 실제 AD FS 로는 시험하지 않았습니다.
# mfa 요구 상태 — Access 가 본 신원(get-identity)
OIDC, 2단계 통과 {"idp":"oidc","amr":["pwd","otp","mfa"]} → Success!
OIDC, 2단계 건너뜀 amr ["pwd"] → That account does not have access.
SAML, 2단계 통과 AuthnContextClassRef …MultiFactorAuthentication → That account does not have access.
# 요구를 뺀 대조 — 같은 SAML 로그인
SAML {"idp":"saml"} (amr 없음) → Success!
# 관리자 로그(access_requests, UTC)
05:41:30Z login false warp … saml
05:42:16Z login true warp … oidc
05:42:18Z warp_enrollment true … oidc # Success! 화면에 닿은 순간 찍힘(이 시험은 등록 링크를 열지 않음)
05:43:10Z login true warp … saml # 요구를 뺀 뒤
등록 앱의 허용 IdP 를 비워 두자 로그인 화면에 사내 AD FS(SAML), OIDC IdP 들과 함께 이메일 코드(Email · Send login code)가 나왔습니다. 계정에 연결된 로그인 수단이 모두 보입니다.
WARP 로그인에 인증 앱(MFA)을 걸자 사용자가 인증 앱을 등록하지 못하고 잠깁니다
단말 등록 정책에 Access 자체 MFA 를 넣으면 WARP 로그인에서도 인증 앱 코드를 묻습니다. 인증 앱은 App Launcher 에서만 등록할 수 있어서, App Launcher 허용 정책에 없는 사용자는 That account does not have access. 에 막혀 잠겼습니다. 명단에 넣자 곧바로 풀렸습니다.
Linux
재현됨
WARP 로그인이 No authentication methods set up 에서 멈췄고, App Launcher 로그인은 That account does not have access. 로 거부됨. 명단에 넣은 뒤에는 인증 앱 등록과 TOTP 6자리 로그인까지 끝남
WARP 로그인에 MFA 를 걸 때는 등록 정책에 MFA 를 넣고, App Launcher 에도 그 사용자들을 허용하는 정책을 함께 연결합니다. 실습 PC 에서 검증했습니다. 이 정책이 없으면 사용자는 Setup MFA 에서 잠깁니다.
App Launcher 정책에는 MFA 를 걸지 않습니다. 인증 앱을 처음 등록하는 곳이기 때문입니다. 공식 문서에도 App Launcher 는 MFA 없이 들어갈 수 있어야 한다고 적혀 있습니다. 이번에 직접 시험하지는 않았습니다.
관리자가 사용자의 인증 앱을 지워야 하면 API 로 지웁니다(GET …/access/users/{user_id}/last_seen_identity 에서 mfa_authenticators[].id 를 찾은 뒤 DELETE …/access/users/{user_id}/mfa_authenticators/{authenticator_id}). 실습에서 검증했습니다.
앱 단위 MFA 는 이번에 효과를 확인하지 못했습니다. 효과가 확인된 방법은 정책 단위 설정입니다.
한 고객사가 WARP 로그인에 Access 의 인증 앱 MFA(로그인할 때 휴대폰 앱의 6자리 코드를 한 번 더 묻는 기능)를 걸었습니다. 그러자 사용자가 No authentication methods set up → 로그인 화면 → That account does not have access 순서로 막혀 인증 앱을 등록하지 못했습니다. 그 조직의 App Launcher(사용자가 로그인해 앱 목록과 계정 설정을 보는 포털)에는 허용 정책이 연결되어 있지 않았습니다. 이전 실습에는 「WARP 등록에서는 MFA 를 묻지 않는다」고 기록되어 있어서 두 기록이 서로 맞지 않았습니다.
실습 조직에서 단말 등록 규칙(Device enrollment permissions)이 쓰는 Access 정책에 정책 단위 MFA 를 넣었습니다(mfa_config: TOTP 만, 5분).
Linux 실습 PC 의 브라우저에서, App Launcher 허용 명단에 없는 이메일로 WARP 등록 로그인(이메일 일회용 코드)을 했습니다. 그다음 화면에 나온 Setup MFA 를 눌렀습니다.
App Launcher 정책 명단에 그 사용자를 넣은 뒤 인증 앱 등록과 WARP 로그인을 다시 했습니다.
정책 단위 MFA 는 WARP 등록 로그인에도 걸렸습니다. 이메일 코드를 넣은 뒤, 인증 앱이 없는 사용자에게는 No authentication methods set up 이 나왔습니다. 인증 앱을 등록한 뒤에는 Authenticate your identity(TOTP 6자리) 화면이 나왔고, 6자리를 넣어야 등록이 끝났습니다. 이미 로그인 세션이 있던 창에서도 같은 화면이 나왔습니다. 이전 실습의 「묻지 않는다」는 조직 단위 MFA 만 켠 경우였습니다.
Setup MFA 를 누르면 App Launcher 로 넘어갔고, 인증 앱은 그 안에서만 등록할 수 있었습니다. 명단 밖 계정으로 로그인하면 That account does not have access. 가 나와 더 나아갈 수 없었습니다. 이 문구는 다른 PC 의 브라우저에서 실습 IdP 로 로그인해 확인했습니다. Linux 실습 PC 에서 이메일 코드를 요청했을 때는 코드가 오지 않았습니다. 하지만 1분 반 전에 같은 주소로 코드를 받은 직후라 이 결과만으로는 판단하지 않았습니다.
App Launcher 정책에 그 사용자를 넣자, 로그인 세션이 있던 창에서 Setup MFA 가 곧바로 Secure your account with MFA 화면을 열었습니다. 인증 앱 등록(MFA setup complete!)과 WARP 단말 등록도 끝까지 됐습니다.
WARP 로그인 앱에 앱 단위 MFA(mfa_config)를 넣는 API 요청은 오류 없이 받아들여졌습니다(이전 실습에서 Pulumi 공급자는 거부했습니다). 실제로 WARP 로그인에 적용되는지는 시험하지 않고 바로 되돌렸습니다.
# 단말 등록 정책에 넣은 값 (PUT …/access/policies/<uuid>)
"mfa_config": {"allowed_authenticators": ["totp"], "session_duration": "5m"}
# 인증 앱 등록 뒤 WARP 등록 로그인 — 요청 로그
06:24:04Z warp_enrollment true … onetimepin
# WARP 로그인 앱에 앱 단위 mfa_config 를 넣은 PUT, 이어진 GET
"errors": []
"mfa_config":{"allowed_authenticators":["totp"],"session_duration":"5m","mfa_disabled":false}
단말 등록 정책에 MFA(TOTP)를 넣은 뒤 WARP 등록 로그인에서 이메일 코드를 넣자 No authentication methods set up 화면이 나왔습니다. 이 사용자는 아직 인증 앱이 없어서 여기서 멈췄습니다.
다른 VPN 을 같이 켜면 WARP 와 IP 대역 · DNS 가 겹칩니다
두 번째 VPN 이 WARP 가상 IP 대역을 경로로 가져가고 DNS 도 함께 맡으려 들었지만, WARP 는 Connected 를 유지했고 인터넷·DNS 도 됐습니다. 고객 현장에서 난 연결 불가·끊김까지는 재현되지 않았습니다.
Linux
부분 재현
두 번째 VPN 이 WARP 가상 IP 경로와 DNS 를 가져갔지만 WARP 는 Connected 유지, 인터넷·DNS 정상
고객 현장의 연결 불가·끊김은 재현하지 못해, 아래 방법이 그 증상을 고치는지는 확인하지 못했습니다.
DNS 는 한쪽만 맡게 합니다. wg1 설정에서 DNS 줄을 지우자 wg1 쪽 DNS 가 비었고, 없는 이름 조회가 약 40ms 에서 약 20ms 로 빨라졌습니다(실습 PC 에서 확인).
다른 VPN 의 경로(WireGuard 라면 AllowedIPs)에서 WARP 가상 IP 대역 100.96.0.0/12 를 뺍니다. 이 실습에서는 끊김이 없어 효과를 확인하지 못했습니다.
한 고객사의 협력사 단말에서, 이미 쓰던 상용 SASE VPN 과 WARP 를 함께 켜면 두 VPN 이 IP 대역과 DNS 를 두고 부딪혀 연결이 안 되거나 간헐적으로 끊긴다는 문제가 있었습니다.
상용 SASE 라이선스가 없어, Linux 실습 PC(Ubuntu 26.04)에 두 번째 VPN 으로 WireGuard 인터페이스 wg1 을 올렸습니다. 상대편(피어)은 응답하지 않는 예시 주소입니다.
wg1 이 WARP 가상 IP 대역(WARP 가 단말마다 나눠 주는 사설 주소, 100.96.0.0/12)을 경로로 잡고, 응답 없는 DNS 서버 192.0.2.53 도 내려 주게 했습니다.
WARP 터널 프로토콜을 MASQUE 와 WireGuard 로 바꿔 가며 경로·DNS·연결 상태를 보고, wg1 의 DNS 설정을 뺐을 때와 비교했습니다.
wg1 을 켜자 100.96.0.1 로 가는 길이 wg1 로 바뀌었습니다. 이 단말에서 다른 WARP 단말의 가상 IP 로 가는 트래픽이 그 VPN 으로 새어 나간다는 뜻입니다.
두 인터페이스가 모두 모든 이름(~.)의 DNS 를 맡겠다고 나섰습니다. 응답 없는 DNS 가 함께 걸려 있어 조회가 조금 느려졌지만 실패하지는 않았습니다(시스템이 두 쪽에 함께 묻고 먼저 온 답을 씀).
MASQUE 에서는 Connected / healthy 가 그대로였고 인터넷·DNS·터널(warp=on)이 모두 됐습니다. WireGuard 로 바꿔도 터널 요청이 0.12초에 성공했습니다.
이 실습의 두 번째 VPN 은 트래픽을 실제로 빼앗지 않으므로, 상용 SASE 에서 난 연결 불가·끊김은 재현하지 못했습니다.
$ ip route get 100.96.0.1
100.96.0.1 dev wg1 src 10.99.0.2 uid 1001
$ resolvectl domain | grep -E "wg1|CloudflareWARP"
Link 105 (CloudflareWARP): ~.
Link 107 (wg1): ~.
$ warp-cli status
Status update: Connected
Network: healthy
두 번째 VPN(wg1)을 WARP 가상 IP 대역으로 켠 뒤의 화면입니다. 100.96.0.1 로 가는 길이 wg1 로 잡히고 두 인터페이스가 모두 DNS ~. 를 맡았지만, WARP 는 Connected · healthy 를 유지했습니다.
작은 리눅스 서버에서 warp-svc 가 Watchdog reports hung daemon 경고를 25번 쌓다가 멎은 일이 있었습니다
CPU·메모리 상한을 걸고 60초마다 연결을 끊었다 붙이기를 90분 동안 45번 했지만 hung daemon 경고는 한 줄도 나오지 않았습니다. 원래 장애는 설치하고 약 2.5일 뒤에야 처음 나온 간헐 현상이라, 이번 결과만으로 이 장애가 생기지 않는다고 볼 수는 없습니다.
Linux
재현 못 함
90분 동안 45번 끊었지만 hung daemon 경고 0줄, 서비스 재시작 0번. 메모리 최고치 약 118MB(상한 320MB)
확인된 해결책은 아직 없습니다. 다시 나면 그때의 진단 묶음(warp-diag)을 받아 두십시오. 받는 방법은 「진단 내보내기 없음」 항목에 있습니다.
고객 작업을 미리 시험하려고 돌리던 작은 리눅스 서버(vCPU 2, 메모리 1GB, swap 없음)에서 났습니다. warp-svc 가 Watchdog reports hung daemon(백그라운드 서비스가 응답 없이 붙잡혀 있다는 감시 경고)을 25번 남긴 뒤 서버가 멎었고, 원격 관리 연결도 끊겼습니다. 경고에는 한 작업자가 약 999초 동안 붙잡혀 있었다는 값(max_busy_duration)과 연결 재시도 경로(happy_eyeballs_with_retry)가 찍혀 있었습니다.
리눅스 실습 PC(Ubuntu, 원래 장애와 같은 클라이언트 2026.7.1377.0)에서 systemd 로 warp-svc 에 CPU 40%·메모리 320MB 상한을 걸어 작은 서버와 비슷한 조건을 만들었습니다.
90분 동안 60초 막고 60초 푸는 것을 45번 되풀이했습니다. 막는 동안에는 바깥 UDP(443 · 500 · 1701 · 2408 · 4443 · 4500 · 8095 · 8443)와 MASQUE 서버로 가는 TCP 443 을 막았습니다.
10초마다 warp-cli status 를 기록했고, 끝난 뒤 서비스 로그에서 경고 줄 수를 셌습니다.
hung daemon 경고는 0줄이었고 서비스는 한 번도 재시작되지 않았습니다. 메모리는 최고 약 118MB였고, CPU 는 평균 한 코어의 약 1%를 썼습니다. CPU 상한에 걸린 시간은 모두 합쳐 약 9초였고, 길게 이어진 적은 없었습니다.
막을 때는 45번 모두 끊겼고, 풀면 45번 모두 13초 안에 다시 붙었습니다. 재시도 실패 줄(Happy Eyeballs check failed)이 1,886번 찍혀, 원래 장애에 나온 재시도 경로도 이번에 계속 돌았습니다.
막힌 60초 동안에는 원격 관리 연결도 끊겼습니다. 이 PC 는 관리 트래픽도 WARP 터널로 나가기 때문입니다. 그래도 PC 자체가 멎지는 않았습니다.
10초마다 CPU% 를 기록하려던 표본은 기록 실수로 비어 있어서, 최대 CPU% 는 알 수 없습니다.
systemd 드롭인(서비스 설정에 덧붙이는 파일)으로 CPUQuota·MemoryMax 상한을 건 상태에서도 45번 모두 13초 안에 다시 붙어, 상한이 연결 기능을 해치지는 않았습니다. 이 상한으로 hung daemon 을 막을 수 있는지는 경고가 나오지 않아 확인하지 못했습니다. 드롭인은 설정 관리 도구를 거치지 않는 수동 변경이라, 운영 서버에 넣는다면 변경 기록을 따로 남겨야 합니다.
warp-svc 에 CPU 40%·메모리 320MB 상한을 걸고 60초마다 연결을 끊었다 붙이기를 45번 했지만, 로그의 hung daemon 경고는 0줄이었습니다. 재시도 실패 줄(Happy Eyeballs check failed)은 1,886번 찍혔지만 서비스는 한 번도 재시작되지 않았습니다(화면의 시각은 UTC 입니다).
사용자 좌석을 정리하자 그 사람의 단말이 끊기고 다시 로그인하라는 창이 뜹니다
좌석을 거두면 1~2초 안에 그 사용자의 단말 등록이 취소됐고, 44~73초 뒤 세 OS 모두 연결을 잃고 로그인 창을 띄웠습니다. 다시 로그인하면 바로 붙지만 좌석도 다시 쓰고, 브라우저에서 로그인만 해도 좌석이 잡혔습니다.
Windows
재현됨
반납 1.8초 뒤 등록 취소, 73초 뒤 로그인 창. 다시 로그인하면 새 등록으로 연결
macOS
재현됨
같은 초에 등록 취소, 56초 뒤 3008 Device authorization failed 로 등록을 잃고 창이 스스로 앞으로 나옴
Linux
재현됨
2초 뒤 등록 취소, 44초 뒤 Registration Missing. 10분 동안 스스로 돌아오지 않음
좌석 정리는 곧 그 사용자의 단말 등록 해제입니다(공식 문서도 연결된 등록을 모두 지운다고 적고 있습니다). 정리하기 전에 대상자의 단말 등록 목록을 보고 지금 쓰는 사람은 빼며(API: GET /devices/registrations), 대상자에게 먼저 알립니다.
이미 끊긴 사용자는 클라이언트가 띄운 로그인 창에서 다시 로그인하면 바로 다시 붙었습니다(세 OS 확인). 이때 좌석을 다시 씁니다.
퇴사자처럼 영구히 막으려면 좌석 정리·사용자 삭제만으로는 안 됩니다(다시 로그인하면 좌석을 되찾는 것을 확인). 공식 문서대로 IdP 계정을 끄거나 단말 등록 정책(Device enrollment)에서 빼야 하며, 이 방법은 실습 PC 에서 따로 검증하지 않았습니다.
한 고객사에서 관리자가 대시보드의 사용자 정리(좌석 회수)를 하자, 감사 로그에 같은 초에 그 사용자들의 단말 등록 취소가 한꺼번에 찍혔습니다. 해당 사용자들은 다시 로그인해야 했습니다.
세 OS 실습 PC 를 사람이 One-time PIN(메일로 받는 일회용 코드)으로 로그인한 신원 등록으로 붙여 두었습니다.
고객 감사 로그에 남은 것과 같은 좌석 반납 동작(PATCH /access/seats)으로 그 사용자의 좌석을 거두고, 10분 남짓 등록 상태·사용자 목록·클라이언트 상태를 1분 안팎 간격으로 봤습니다. 대시보드 버튼은 쓰지 않았습니다.
같은 신원으로 다시 로그인해 보고, 사용자 삭제(DELETE /access/users/{id})와도 비교했습니다.
새 주소로 브라우저 로그인만 하고 클라이언트를 여는 단계는 취소해, 좌석이 잡히는지 봤습니다.
좌석을 거두면 1~2초 안에 그 사용자의 단말 등록이 취소(revoke)됐습니다. 클라이언트는 44~73초 동안 Connected 로 보이다가 등록을 잃고 Registration Missing due to: Does not exist in API 가 되며 Awaiting external log in 창을 띄웠습니다. macOS 는 창을 스스로 앞으로 띄웠습니다.
사람이 다시 로그인할 때까지 돌아오지 않았고(Linux 10분 관찰), 그동안 인터넷은 WARP 를 거치지 않고 그대로 나갔습니다(macOS 에서 확인).
좌석 반납은 사용자 줄을 목록에 남겼고, 사용자 삭제는 줄까지 없앴습니다. 둘 다 2초 안에 등록을 취소했습니다(macOS·Linux).
다시 로그인하면 새 Device ID 로 바로 붙고 좌석이 다시 켜졌습니다. 지운 사용자도 다시 로그인하자 같은 id 로 되살아났습니다(macOS·Linux). 브라우저에서 로그인 성공 화면까지만 가도(클라이언트 열기 취소) 좌석이 1개 잡혔습니다(세 OS).
# macOS — 좌석 반납 56초 뒤 클라이언트 서비스 로그
DEBUG … warp_api_client::errors: API error code requires daemon action=DeleteRegistration status_code=401 error_message=WarpApiErrorMessage { code: 3008, message: Some("Device authorization failed") …
WARN … actor_api::handler: Detected invalidated registration. Notifying main loop compat actor. reg_id=t.<uuid> reason=ApiMismatch
DEBUG handle_invalidated_registration{…}: actor_configuration::handler::registration: Deleting 'Does not exist in API' registration
# 그 뒤 warp-cli status (세 OS 같음)
Status update: Unable
Reason: Registration Missing due to: Does not exist in API
# Linux — warp-cli registration show
Error: Missing registration. Try running: "warp-cli registration new"
좌석을 반납하자 같은 초에 등록이 취소됐고, 56초 뒤 클라이언트가 등록을 잃고 창을 스스로 앞으로 띄워 Awaiting external log in 에서 로그인을 기다렸습니다. 이 화면은 반납 약 26분 뒤이며, 그때까지 스스로 돌아오지 않았습니다.
로그인 성공 화면이 Authentication Expired 로 바뀌고, 재부팅해도 다시 로그인하지 않습니다
성공 화면이 뜬 뒤 약 1분 안에 Open 을 누르지 않으면 토큰이 만료돼 등록이 거부됐습니다. 단말 시계를 ±90초 어긋나게 해서는 실패하지 않았고, 한 번 등록한 단말은 재부팅하거나 Access 세션을 취소해도 연결이 그대로였습니다.
Windows
부분 재현
성공 화면 56초부터 Authentication Expired, 70초 뒤 누른 Open 은 거부됨. 시계를 +30·+90초 어긋나게 해도 등록됨. 재부팅 뒤 로그인 없이 Connected
Linux
부분 재현
67초 화면부터 Authentication Expired, 72초 뒤 누른 Open Link 는 거부됨. 시계를 +30·+90·−90초 어긋나게 해도 등록됨. 재부팅 뒤 같은 단말 ID 로 Connected
로그인 성공 화면이 뜨면 1분 안에 Open 을 누릅니다. 늦었으면 클라이언트 창의 Retry login(Windows)·Relaunch browser(Linux)로 다시 받습니다. 브라우저 세션이 살아 있어 코드 없이 곧바로 성공 화면이 떴습니다(두 OS 확인).
단말 시계는 NTP(자동 시간 동기화)로 맞춰 둡니다. 이번 실습에서는 ±90초도 등록을 막지 않았지만, Edge 에서는 화면 버튼이 처음부터 만료로 보였습니다.
재부팅이나 revoke_user 로는 다시 로그인시킬 수 없습니다. 꼭 다시 로그인시켜야 하면 단말 등록을 지우거나 좌석을 정리해야 합니다. 그러면 로그인 창이 다시 뜨는 것을 「좌석 정리로 끊김」 항목과 「같은 PC 여러 줄」 항목에서 확인했습니다.
클라이언트 창이 Checking your organization configuration... 에서 멈추면 창 프로그램만 다시 띄웁니다. Linux 는 systemctl --user restart warp-taskbar, Windows 는 앱을 끝내고 다시 열었습니다(서비스 재시작으로는 풀리지 않음). 두 OS 에서 확인했습니다.
한 고객사 현장에서 「재부팅해도 세션이 남아 다시 로그인하지 않는다」와 「세션을 취소(revoke)해도 IdP(회사 로그인 서버) 쿠키로 비밀번호 없이 다시 들어온다」가 문제가 됐습니다. 공식 문서의 「단말 시계가 20초 넘게 어긋나거나 1분 안에 돌아오지 않으면 Authentication Expired」 설명도 함께 확인했습니다.
Windows·Linux 실습 PC 를 One-time PIN(메일로 받는 일회용 코드)으로 사람이 로그인해 등록했습니다. Windows 는 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
로그인 성공 화면에서 Open 을 누르지 않고 4~12초 간격으로 화면을 찍어 버튼이 바뀌는 때를 쟀고, 70초 넘게 지나 Open 을 눌렀습니다.
자동 시간 동기화를 끄고 단말 시계를 +30초·+90초(Linux 는 −90초도) 옮긴 채 다시 로그인했습니다.
등록된 채 재부팅했고, API 로 Access 세션을 취소(revoke_user)한 뒤 150초 동안 연결 상태를 보고 같은 브라우저로 로그인 주소를 다시 열었습니다.
성공 화면의 토큰은 약 1분만 유효했습니다. 버튼이 Windows 는 56초, Linux 는 67초 화면부터 Authentication Expired 로 바뀌었고, 그 뒤 누른 Open 은 401 · code 1039 · oidc: token is expired 로 거부됐습니다. 클라이언트 창은 이유를 보여 주지 않았습니다(Windows 는 Retry login, Linux 는 Awaiting external log in 그대로).
시계를 어긋나게 해도 곧바로 Open 을 누르면 등록됐습니다. 서버가 토큰 시각을 판정하므로, 결과를 가른 것은 성공 화면부터 Open 까지 실제로 걸린 시간이었습니다. Windows 의 Edge 는 +90초일 때 버튼이 처음부터 만료로 보였지만, 위쪽 확인 창의 Open 은 됐습니다.
등록한 단말은 재부팅해도 로그인 창 없이 Connected 였고 등록도 그대로였습니다. revoke_user 도 WARP 연결을 끊지 않았습니다(150초 관찰).
revoke 뒤 같은 브라우저로 로그인 주소를 열면 Linux(Firefox)는 코드를 다시 물었고, Windows(Edge)는 ERR_TOO_MANY_REDIRECTS 가 났습니다. 고객 현장의 「IdP 쿠키로 비밀번호 없이 재진입」은 실습 IdP 계정을 쓸 수 없어 확인하지 못했습니다.
# Linux — 성공 화면을 열고 72초 뒤 Open Link
DEBUG api_request{… request=new_registration}: warp_api_client::errors: API error code requires daemon action=DeleteRegistration status_code=401 error_message=WarpApiErrorMessage { code: 1039, message: Some("oidc: token is expired (Token Expiry: 2026-09-24 00:14:30 +0000 UTC)") } url=None
INFO … request=new_registration}: actor_api::handler: Request completed in 349ms result=Err(ForceNoRetry(DetectedInvalidatedRegistration(ApiMismatch)))
# 그때 warp-cli status
Unable / Reason: Registration Missing due to: Does not exist in API
# 시계 +30초, 곧바로 Open Link
request=new_registration … Request completed in 706ms result=Ok
# revoke_user 응답, 그 뒤 150초 동안 warp-cli status
{"result":true,"success":true}
Status update: Connected
로그인 성공 화면에서 Open 을 누르지 않고 기다리자, 56초까지 파란 버튼이던 것이 67초 화면에서는 Authentication Expired 로 바뀌어 눌러도 반응이 없었습니다.
단말 목록에 같은 PC 가 여러 줄로 쌓입니다
서비스 토큰으로 배포한 단말은 등록을 지우거나 revoke 해도 1분 안팎에 스스로 새 Device ID 로 다시 등록해, 지울 때마다 줄이 하나씩 늘었습니다. 단말 쪽에서만 지우거나 재설치하면 옛 줄이 대시보드 목록에 그대로 보이는 유령 줄로 남았습니다(재설치로 줄이 남은 것은 macOS·Linux 입니다. Windows 는 재설치 때 서버 줄도 함께 사라졌습니다).
Windows
부분 재현
관리 API 를 막은 채 단말에서 지운 줄이 3시간 남음(유령 줄). API 로 지우면 35초 만에 새 등록, 기본 목록에 옛 줄 없음. 재설치는 줄을 늘리지 않음
macOS
재현됨
API 삭제 46초·revoke 57초·키체인 삭제 5초 뒤마다 새 ID 로 다시 연결. Mac 한 대가 숨은 줄 포함 12줄까지 쌓임
Linux
재현됨
API 삭제 61초·revoke 39~92초·reg.json 삭제 3초 뒤 새 ID 로 다시 연결. 한 대가 숨은 줄 포함 11줄까지 쌓임
정리할 때는 지금 쓰는 등록 하나만 남기고, 나머지 등록을 지운 다음 기기 줄을 지웁니다(API: DELETE /devices/registrations/{id} → DELETE /devices/physical-devices/{id}). macOS·Linux 에서 숨은 줄까지 1줄만 남고, 쓰던 단말은 연결을 유지한 것을 확인했습니다.
숨은 줄까지 보려면 기기 목록을 숨은 등록까지 포함해 조회합니다(API: GET /devices/physical-devices?active_registrations=include). 여기에 status=all 을 붙이면 오류(2750)가 납니다.
단말에서 등록을 지울 때는 관리 API 에 닿는 상태에서 warp-cli registration delete 를 씁니다(Windows 에서 이때는 옛 줄이 남지 않은 것을 확인). 가상 IP 를 돌려받으려면 revoke 보다 삭제를 씁니다.
재설치 전에 MDM 파일을 따로 보관했다가 되돌립니다(되돌리자 4~7초 만에 등록). macOS 제거 스크립트를 원격으로 돌렸다면 끝난 뒤 API 로 옛 등록을 지웁니다(확인함). 스크립트를 고친 판은 시험하지 않았습니다.
한 고객사의 단말 목록에 같은 PC 가 같은 이름으로 여러 줄 쌓였습니다. 「등록을 지우면 약 95초 뒤 스스로 다시 등록되고, 지운 쪽은 등록 0개짜리 줄로 남는다」는 기록도 있었습니다.
세 OS 실습 PC 를 서비스 토큰(사람 로그인 없이 붙는 기계용 자격)을 넣은 MDM 파일(배포 설정 파일)로 등록해 두고, ① API 로 등록 삭제 ② revoke(등록 폐기) ③ 단말에서만 등록 삭제 ④ 제거 뒤 재설치를 했습니다.
③ 은 Linux 는 reg.json 파일, macOS 는 시스템 키체인 항목을 지웠습니다. Windows 는 등록 파일이 따로 없어, 관리 API 주소를 막은 채 warp-cli registration delete 를 했습니다.
Windows·macOS 는 사람이 One-time PIN(메일로 받는 일회용 코드)으로 로그인한 신원 등록에서도 ① 을 해 비교했습니다.
단말 상태는 1~4초마다 찍고, 서버 목록은 기본 조회와 숨은 줄까지 보는 조회로 셌습니다.
토큰으로 등록한 단말은 지우거나 revoke 해도 스스로 새 Device ID 로 다시 붙었습니다(API 삭제 35~61초, revoke 39~92초, 단말에서만 삭제 3~5초). 기록의 95초보다 빨랐습니다. 신원 등록은 다시 붙지 않고 로그인 창을 띄운 채 기다렸고, Windows 에서는 브라우저에 로그인 탭이 6개 열렸습니다.
API 로 지우거나 revoke 한 옛 줄은 기본 목록에서 숨고(active_registrations: 0), 단말에서만 지우거나 재설치한 옛 줄은 대시보드 목록에도 보이는 유령 줄로 남았습니다(macOS·Linux). Windows(공식 지원 OS 가 아닌 Windows Server 2025)는 API 를 막은 채 지운 줄이 3시간 동안 남았고, 숨은 줄 조회는 하지 않았습니다.
revoke 한 등록은 가상 IP(단말마다 받는 100.96.x 주소)를 쥔 채 남아, 새 등록은 다른 IP 를 받았습니다. API 로 지운 등록의 IP 는 나중 등록이 다시 받았습니다. Windows 와 Linux(두 번 중 한 번)에서는 revoke 직후 Failed to perform happy eyeballs 가 떠, UDP 차단과 같은 문구로 보였습니다.
제거하면 MDM 파일까지 지워졌습니다(Windows msiexec /x, Linux apt-get remove, macOS 공식 제거 스크립트). macOS 제거 스크립트는 원격 root 셸에서 warp-cli 를 찾지 못해 서버 등록을 지우지 못한 채 Finished uninstallation! 으로 끝났습니다.
# Linux — revoke 직후: UDP 차단과 같은 문구로 보임
ERROR main_loop: warp::warp_service: Connection error error=HappyEyeballsFailure(HappyEyeballs(FailedToEstablishTunnel(Custom { kind: Other, error: ConnectionClose(None) })))
WARN api_request{… request=report_aggregate_stats}: actor_api::handler: Detected invalidated registration. Notifying main loop compat actor. reg_id=t.<uuid> reason=ApiMismatch
# 숨은 줄 조회에 status=all 을 붙이면
{"code":2750,"message":"invalid query parameter 'status': not supported"}
# macOS 공식 제거 스크립트(원격 root 셸)
Deleting registration
sudo: warp-cli: command not found
…
Finished uninstallation!
서비스를 멈추고 등록 파일(reg.json)만 지운 뒤 다시 켜자, 몇 초 만에 다른 Device ID 로 Connected 가 됐습니다. 서버에는 옛 줄이 그대로 남아 같은 이름의 단말이 하나 늘었습니다.
Split Tunnel 에 같은 주소를 넣어도 Include · Exclude 모드에 따라 경로가 정반대가 되고, 터널에서 뺀 트래픽은 로그에 남지 않습니다
세 OS 모두 Exclude 목록에 1.0.0.1/32 를 넣으면 그 주소만 터널 밖으로, Include 목록에 넣으면 그 주소만 터널 안으로 갔습니다. 터널에서 뺀 목적지로 간 연결은 Gateway 네트워크 로그에 0건이었고, 도메인으로 뺀 경우에는 이름을 푼 IP 만 터널 밖으로 빠졌습니다.
Windows
재현됨
Exclude 면 1.0.0.1 만 Ethernet, Include 면 1.0.0.1 만 터널. 뺀 example.net 은 네트워크 로그 0건(DNS 로그는 표본 추출 때문에 확인 못 함)
macOS
재현됨
경로가 정반대로 바뀜. 뺀 example.com 은 DNS 로그에 남고 네트워크 로그 0건. 프로필 변경은 3번 모두 2~3분 안에 안 내려와 다시 붙인 뒤 10초 안에 반영
Linux
재현됨
경로가 정반대로 바뀜(1.0.0.1 warp=on ↔ 1.1.1.1 warp=off). 뺀 example.com 은 DNS 로그 3건, 네트워크 로그 0건
「제외 목록에 넣어 달라」는 요청을 받으면 먼저 그 단말 프로필이 Include 모드인지 Exclude 모드인지 봅니다. Include 모드에서는 목록에 없는 목적지가 이미 터널 밖입니다(실습 PC 에서 확인).
바꾼 뒤에는 단말에서 실제 경로를 봅니다. Windows Find-NetRoute -RemoteIPAddress <주소>, macOS route -n get <주소>, Linux ip route get <주소> 결과가 WARP 인터페이스(CloudflareWARP, utunN)면 터널 안입니다.
로그와 Gateway 정책이 필요한 목적지는 Exclude 하지 않고 터널에 남깁니다. 도메인으로 뺄 때는 이름을 푼 IP 만 빠진다는 점을 감안합니다.
프로필을 바꿨는데 단말의 warp-cli settings 가 그대로면 warp-cli disconnect → warp-cli connect 로 다시 붙입니다. macOS 에서 세 번 모두 10초 안에 반영됐습니다. Windows(15~35초)·Linux(36초)는 다시 붙이지 않아도 반영됐습니다.
한 고객사의 협력사가 「IP 3개를 Split Tunnel(어떤 목적지를 WARP 터널에 태우고 뺄지 정하는 목록) 제외 목록에 넣어 달라」고 요청했습니다. 그런데 그 협력사가 쓰는 단말 프로필은 Include 모드(목록에 있는 곳만 터널로 감)였습니다. 여기에 IP 를 넣으면 오히려 터널로 들어가는 정반대 효과가 납니다. 「터널에서 뺀 트래픽은 Gateway 로그에 보이지 않는다」는 점도 함께 짚였습니다.
세 OS 실습 PC 의 단말 프로필에서 Exclude 목록에 1.0.0.1/32 와 도메인 하나(example.com, Windows 는 example.net)를 더했습니다. Office 365 제외와 LAN 예외(30분, /24)도 함께 켰습니다.
이어서 모드를 Include 로 바꾸고 1.0.0.1/32 한 줄만 두었습니다. 단계마다 1.0.0.1 과 1.1.1.1 이 어느 인터페이스로 나가는지 OS 명령(Find-NetRoute, route -n get, ip route get)으로 봤습니다.
뺀 도메인과, 터널로 가는 www.cloudflare.com 에 3번씩 접속한 뒤 Gateway(Cloudflare 쪽 보안 필터)의 DNS·네트워크 로그를 분석 API(GraphQL)로 조회했습니다.
Windows 실습 PC 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
Exclude 에서는 1.0.0.1 만 물리 랜카드로 나가고 1.1.1.1 은 터널로 갔습니다. Include 에서는 반대로 1.0.0.1 만 터널(warp=on)로 가고 1.1.1.1 은 바로 나갔습니다(warp=off). Include 모드에서는 클라이언트가 Cloudflare One 대역 2줄을 스스로 덧붙였습니다.
뺀 목적지로 간 연결은 세 OS 모두 Gateway 네트워크 로그에 0건이었고, 터널로 간 www.cloudflare.com 은 4~7건 남았습니다. DNS 질의는 Linux·macOS 에서 로그에 남았습니다. Windows 는 DNS 로그가 표본 추출(약 10건 중 1건만 기록)돼 이번 질의가 보이지 않아 확인하지 못했습니다.
도메인으로 빼면 이름을 풀기 전에는 그 IP 도 터널로 가다가, 푼 직후 그 IP 만 터널 밖으로 빠졌습니다. 바로 옆 주소는 계속 터널로 갔습니다. 앱이 IP 로 바로 붙거나 다른 DNS 로 이름을 풀면 터널로 간다는 뜻입니다.
Office 365 제외를 켜자 Microsoft 대역 95줄이 저절로 붙어 목록이 113줄이 됐습니다. LAN 예외는 창의 버튼과 warp-cli override local-network allow 명령 모두 30분 허용으로 동작했지만, 실습 PC 의 LAN 이 이미 기본 제외 대역 안에 있어 경로 차이는 보지 못했습니다.
# Windows — Exclude 에 example.net: 이름을 푼 IP 만 터널 밖
example.net A = 104.20.21.8,172.66.175.59
route 104.20.21.8 -> if=Ethernet dest=0.0.0.0/0 nexthop=172.31.0.1
route 172.66.175.59 -> if=Ethernet dest=0.0.0.0/0 nexthop=172.31.0.1
route 104.20.21.9 -> if=CloudflareWARP dest=104.20.21.9/32 (풀지 않은 바로 옆 주소)
# 단말에 내려온 LAN 예외 설정(세 OS 같음)
(network policy) LAN Access Settings: Allowed for 1800 seconds on a /24 subnet
# macOS — 다시 붙인 직후에야 새 설정을 받음
INFO warp::notification: Registration changed. Reconnecting to notifications websocket
기본 상태의 Split Tunnel 창입니다. 조직이 정한 Exclude 16줄(사설 대역 등)만 터널 밖으로 나간다고 보여 줍니다.
단말 프로필 두 개의 조건에 모두 드는 단말이 한쪽 프로필 설정만 받습니다
세 OS 모두 우선순위 번호가 작은 프로필 하나만 적용됐고, 다른 프로필의 Split Tunnel 값은 쓰이지 않았습니다. 새 프로필이 닿기까지 Linux 62초, macOS·Windows 약 6분이 걸렸고(모두 공식 상한 10분 안), 그 뒤 값을 바꾸면 14~52초 만에 반영됐습니다.
Windows
재현됨
만든 지 6분 14초 뒤 Device profile 이 번호가 작은 겹침 프로필로 바뀜(직후 Connecting 한 번). 순서를 되돌리자 27초 만에 원래 프로필로
macOS
재현됨
6분 2초 뒤 겹침 프로필로 바뀌며 그 프로필에만 있는 제외 항목(1.0.0.1)이 적용돼 1.0.0.1 이 터널 밖(en4)으로. 되돌리기 14초
Linux
재현됨
62초 뒤 겹침 프로필로 바뀌어 1.0.0.1 이 터널 밖(ens5)으로. 되돌리기 14초. Include 100.64.0.0/10 이면 가상 IP 가 아닌 CGNAT 주소도 터널로 감
설정을 고치기 전에 그 단말이 어느 프로필을 받았는지 먼저 봅니다(GUI Settings › Device profile, warp-cli settings 의 Profile ID). 세 OS 실습 PC 에서 확인했습니다.
한 사용자·단말이 두 프로필의 조건에 함께 들지 않게 조건을 나눕니다. 겹쳐야 한다면 원하는 프로필을 더 작은 번호로 둡니다. 실습에서 순서를 바꾸자 14~27초 만에 바뀌었습니다.
프로필을 새로 만들거나 바꾼 뒤에는 10분까지 기다립니다. 첫 반영이 OS 에 따라 6분 넘게 걸렸습니다. 그래도 그대로면 다시 연결해 봅니다(이번 실습에서는 필요 없었습니다. 「Split Tunnel 반대로 적용」 항목에서는 macOS 가 다시 붙인 뒤 10초 안에 반영).
100.64.0.0/10 대신 실제로 필요한 대역(가상 IP 라면 100.96.0.0/12)이나 목적지만 넣습니다(효과는 확인하지 않음 — 이번에는 통째로 넣은 영향만 봤습니다).
한 고객사의 Zero Trust 설정 점검에서, 한 계정이 단말 프로필(Device profile, 단말에 내려갈 Split Tunnel 등 설정 묶음) 두 개의 대상 조건에 동시에 들어 있었습니다. 번호가 작은 프로필이 이겨, 다른 프로필에서 열어 둔 사내 앱 몇 개에 닿지 못하는 설정이었습니다. 같은 조직의 다른 프로필은 Split Tunnel Include 목록에 100.64.0.0/10 을 통째로 넣고 있었습니다.
세 OS 실습 PC 는 각자 자기 시험 프로필(우선순위 100·110·120, Exclude 기본 16줄)을 받고 있었습니다. 여기에 세 PC 모두에 맞는 조건으로 겹침 프로필(우선순위 90, Exclude 에 1.0.0.1/32 한 줄 추가)을 만들었습니다. 우선순위(precedence)는 숫자가 작을수록 먼저 봅니다.
10초마다 warp-cli settings 의 Profile ID(GUI 는 Settings › Device profile)를 보며 바뀌는 시각을 쟀고, 바뀐 뒤 1.0.0.1 과 1.1.1.1 이 어느 인터페이스로 나가는지 봤습니다.
이어서 겹침 프로필을 Include 100.64.0.0/10 으로 바꿨다가, 우선순위를 130(시험 프로필보다 아래)으로 내려 원래 프로필로 돌아가는 시간을 쟀습니다.
Windows 실습 PC 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
세 OS 모두 번호가 작은 겹침 프로필 하나만 받았습니다. 1.0.0.1 은 겹침 프로필의 제외 항목대로 터널 밖(Linux ens5, macOS en4)으로 나갔고, 원래 시험 프로필의 설정은 쓰이지 않았습니다. 순서를 되돌리자 1.0.0.1 도 다시 터널로 갔습니다.
새 프로필이 닿기까지 Linux 62초, macOS 6분 2초, Windows 6분 14초였습니다. 같은 변경 알림이 Linux 에는 1분 만에, macOS·Windows 에는 5분 늦게 도착했습니다. 이후 값을 바꾸거나 순서를 되돌린 것은 14~52초 만에 반영됐고, 모두 공식 문서의 「최대 10분」 안이었습니다.
100.64.0.0/10(CGNAT, 통신사·회사망이 여러 사용자에게 나눠 쓰는 공유 주소 대역)을 통째로 Include 에 넣으면 WARP 가상 IP(100.96.0.0/12, 단말마다 붙는 터널 주소)뿐 아니라 가상 IP 가 아닌 주소(100.64.0.1·100.100.100.100)까지 모두 터널로 갔습니다. 실습 조직은 단말끼리 통신(WARP-to-WARP)이 꺼져 있어 그 주소들에 닿지 못했습니다. Include 모드라 나머지 인터넷은 Gateway 를 거치지 않았습니다(gateway=off).
Windows 로그에는 프로필이 바뀔 때 allow_managed_deployments 도 true->false 로 함께 바뀌었습니다. 원래 프로필에만 버전 지정 배포가 걸려 있었으므로(「옛 버전으로 내리기」 항목), 프로필이 바뀌면 그 배포에서도 빠지는 것으로 보입니다(추정 — 공식 문서로 확인하지 않음).
# Linux — 겹침 프로필을 만들고 62초 뒤 (시각 KST, 시험 프로필 → 겹침 프로필)
17:58:06 WARN warp::notification: direct nof from api notif=Notification { id: "<uuid>", inner: Notification(UpdateConfig) }
INFO actor_configuration::handler::settings: User Settings Changed: {profile_id: "<uuid>"->"<uuid>"
# macOS — 같은 알림이 6분 2초 뒤에 도착, 겹침 프로필의 제외 항목이 함께 내려옴
18:03:06 User Settings Changed: {profile_id: "<uuid>"->"<uuid>", split_config.excluded_ips: Added: [["1.0.0.1/32","S12 overlap marker"]]
# Windows — 6분 14초 뒤, 배포 허용 값도 함께 바뀜
18:03:19 User Settings Changed: {profile_id: "<uuid>"->"<uuid>", allow_managed_deployments: true->false, …
# Windows — 우선순위를 130 으로 내리고 27초 뒤 원래 프로필로
18:19:18 … allow_managed_deployments: false->true, profile_id: "<uuid>"->"<uuid>"
겹침 프로필을 만들고 2분 뒤의 Settings 화면입니다. Device profile 은 아직 원래 시험 프로필 값입니다.
사내 앱 이름이 사설 IP 로 풀리면서 사설 대역 차단 규칙에 걸려 화면이 뜨지 않습니다
Local Domain Fallback(LDF)에 없는 사내 이름은 Gateway 가 풀어 사설 IP 를 돌려줬고, 사설 대역을 막는 네트워크 규칙에 걸려 TLS 첫 인사 직후 끊겼습니다. 이름을 LDF 에 넣거나, SNI 허용 규칙을 차단 규칙 앞에 두면 둘 다 풀렸습니다.
Linux
재현됨
LDF 에 없는 이름이 172.31.0.19(사설)로 풀려 0.21초 만에 unexpected eof while reading, Gateway 로그는 block. LDF 추가·SNI 허용 규칙 둘 다 allow 로 바뀜
사내 앱 이름을 LDF 에 넣어 사내 DNS 가 풀게 합니다(단말 프로필의 Local Domain Fallback 에 도메인과 DNS 서버 추가). Linux 실습 PC 에서 확인했고, 설정을 바꾸고 약 35초 뒤 클라이언트에 반영됐습니다.
또는 그 이름을 허용하는 네트워크 규칙을 사설 대역 차단 규칙보다 앞 순서에 둡니다(선택자 SNI, 동작 Allow). Linux 실습 PC 에서 확인했습니다.
같은 차단에 걸린 이름을 찾을 때는 Gateway 네트워크 로그를 block 과 그 차단 규칙으로 좁혀 SNI 목록을 봅니다.
한 고객사에서 Android 단말의 업무 앱 화면이 뜨지 않았습니다. 앱 서버 이름이 LDF(지정한 이름만 사내 DNS 로 보내는 목록)에 빠져 있었고, Gateway(Cloudflare 쪽 DNS·네트워크 필터) 로그에는 그 이름(SNI)으로 사설 대역 전체를 막는 규칙 All Block 의 차단이 찍혀 있었습니다. 단말에는 연결이 닫혔다는 오류(ERR_CONNECTION_CLOSED)만 보였습니다.
공개 DNS 가 사설 IP 172.31.0.19 를 돌려주는 이름(appc.172-31-0-19.sslip.io)을 썼습니다. 그 주소 뒤에는 Cloudflare Tunnel 로 닿는 실습 서버가 있습니다.
사설 대역(10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16)을 막는 Gateway 네트워크 규칙을 Linux 실습 PC 에만 걸고, Split Tunnel 은 Include 모드로 두 주소(172.31.0.19/32 · 1.1.1.1/32)만 터널에 태웠습니다. 사례는 기본 프로필(Exclude 모드)이었지만 원인 사슬은 같습니다.
실습 PC 에 작은 DNS 서버(dnsmasq)를 띄워 「사내 DNS」 역할을 맡기고, 같은 이름에 공인 IP 1.1.1.1 을 답하게 했습니다.
① LDF 없음 ② LDF 에 이 이름을 넣음 ③ LDF 를 빼고 SNI 허용 규칙을 차단 규칙 앞에 둠, 세 상태에서 curl 을 실행하고 Gateway 네트워크 로그를 봤습니다.
LDF 에 없으면 Gateway 가 이름을 풀어 사설 IP 를 줬습니다. TCP 는 붙었지만 TLS Client hello(접속할 이름 SNI 가 담긴 첫 인사)를 보낸 직후 끊겼고, Gateway 로그는 사례와 같은 모양(block, 그 SNI, 차단 규칙)이었습니다.
LDF 에 넣자 실습 PC 의 사내 DNS 가 공인 IP 를 답해 어느 규칙에도 걸리지 않았습니다(allow, 규칙 없음). 이때 난 인증서 이름 오류는 1.1.1.1 이 다른 이름의 인증서를 내서 생긴 것이고 차단이 아닙니다.
SNI 허용 규칙을 앞에 두면 같은 사설 IP 로도 터널 뒤 서버까지 가서 그 서버의 인증서를 받았습니다(allow, 허용 규칙).
resolver policy(이름별로 물어볼 DNS 서버를 정하는 Gateway 규칙)는 이 실습 조직(무료 요금제)에서도 만들어졌지만, 조건에 기기 상태(device posture)는 쓸 수 없었습니다(API 400). 켰을 때 LDF 와 어느 쪽이 이기는지는 시험하지 않았습니다.
# ① LDF 없음 — Gateway 가 사설 IP 를 줌
getent: 172.31.0.19 STREAM appc.172-31-0-19.sslip.io
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLS connect error: error:0A000126:SSL routines::unexpected eof while reading
# ② LDF 에 추가 — 사내 DNS 가 공인 IP 를 줌
getent: 1.1.1.1 STREAM appc.172-31-0-19.sslip.io
# Gateway 네트워크 로그 (UTC, SNI appc.172-31-0-19.sslip.io)
07:22 block 172.31.0.19:443 rule=<uuid> (사설 대역 차단)
07:29 allow 1.1.1.1:443 rule="" (어느 규칙에도 안 걸림)
07:32 allow 172.31.0.19:443 rule=<uuid> (SNI 허용)
LDF 에 없는 이름이 사설 IP 172.31.0.19 로 풀렸고, 사설 대역 차단 규칙에 걸려 TLS 연결이 끊겼습니다(unexpected eof). 화면의 시각은 UTC 입니다.
같은 사설 목적지에 네트워크 정책과 Access 앱을 함께 걸자 인증이 두 번 걸리고 접속이 막힙니다
WARP 에 로그인한 뒤에도 Access 사설 앱은 브라우저에서 인증(로그인이나 앱 정책의 MFA)을 따로 요구했습니다. 같은 목적지에 신원 조건 네트워크 허용 규칙을 겹쳐도 이 인증은 없어지지 않았습니다. 접속이 막힌 경우는 앱 정책의 MFA 를 통과하지 못했을 때와 같은 규칙이 차단일 때였습니다.
Linux
부분 재현
인증 두 번은 재현됨. WARP 로그인 뒤 브라우저가 MFA 화면이나 로그인 화면으로 갔고, 허용 규칙에 잡힌 세션도 같았음. 허용 규칙으로는 막히지 않았고, 차단으로 바꾸자 Connection reset by peer
Access 사설 앱으로 보호하는 목적지는 인증을 앱 정책 하나로 정합니다. 같은 목적지에 건 신원 조건 네트워크 정책은 인증을 대신하지 못했고, 허용 규칙에 잡혀도 로그인 화면이 나왔습니다. 차단 동작이면 앱 정책과 상관없이 막았습니다. 두 가지 모두 실습 PC 에서 확인했습니다. 네트워크 정책은 경로를 여는 용도로만 둡니다.
MFA 까지 통과해 앱 화면이 열리는 것은 이번에 보지 않았습니다. 이 사용자에게 인증 앱이 없었기 때문입니다.
사용자가 「WARP 로그인했는데 또 로그인하라고 한다」고 하면, 그 목적지에 Access 사설 앱이 있는지와 앱 정책이 MFA 를 요구하는지부터 봅니다. MFA 화면에서 멈추면 「MFA 등록 잠김」 항목을 봅니다.
네트워크 규칙을 바꾼 직후 몇 분은 이전 판정이 남을 수 있었습니다. 바로 판정하지 말고 Gateway 네트워크 로그의 규칙 id 로 확인합니다.
한 고객사에서 사내 웹 앱 몇 개를 Access 앱으로 만들었습니다. 그 뒤 같은 대상에 이미 걸려 있던 네트워크 정책과 겹쳐 「인증이 두 번 걸리고 접속이 막혔다」는 장애가 났습니다. 운영 담당자에게 말로 전해 들은 내용이고, 당시 네트워크 정책의 동작과 순서는 기록으로 남아 있지 않습니다.
사설 주소 172.31.0.19:80 에 Access 사설 앱을 두었습니다. 사설 앱은 사설 IP 를 대상으로 만드는 self-hosted 앱입니다. 앱 정책은 사내 도메인 허용에 Access 자체 MFA(인증 앱, 5분)를 더했습니다.
Linux 실습 PC 는 이메일 일회용 코드로 WARP 에 로그인했습니다(첫 번째 인증). Split Tunnel Include 로 이 주소만 터널에 태웠습니다.
같은 목적지·포트에 사용자 이메일을 조건으로 건 Gateway 네트워크 Allow 규칙을 만들었습니다. 규칙을 끈 상태와 켠 상태에서 같은 브라우저의 새 탭과 새 비공개 창(쿠키 없음)으로 각각 열었습니다. 마지막으로 같은 규칙을 Block 으로 바꿔 봤습니다.
WARP 로그인 뒤에도 사설 앱은 브라우저 인증을 따로 요구했습니다. 같은 브라우저에서는 이메일을 다시 묻지 않고 앱 정책의 MFA 화면(No authentication methods set up)으로 갔습니다. 비공개 창에서는 앱 로그인 화면부터 나왔습니다.
신원 조건 허용 규칙을 켜도 달라지지 않았습니다. Gateway 로그에서 그 허용 규칙에 잡힌 세션도 브라우저에서는 Access 로그인 화면으로 갔습니다. 공식 문서에도 Gateway Allow 정책은 Access 정책을 건너뛰게 하지 않는다고 적혀 있습니다.
막힌 경우는 두 가지였습니다. 하나는 사용자가 앱 정책의 MFA 를 통과할 수 없을 때입니다(이번 사용자는 인증 앱이 없어 MFA 화면에서 멈춤). 다른 하나는 같은 목적지의 네트워크 규칙이 차단일 때입니다(Connection reset by peer). 사례가 둘 중 어느 쪽이었는지는 가리지 못했습니다.
규칙을 켜고 약 2~4분 동안은 세션이 그 규칙에 잡히지 않았습니다(로그의 규칙 id 가 빈 값). 원인은 확인하지 않았습니다.
# WARP 로그인 뒤, 규칙 꺼짐 — 사설 앱이 Access 로그인으로 보냄
HTTP/1.1 302 Found
Location: https://mzccfteam.cloudflareaccess.com/cdn-cgi/access/login/172.31.0.19?kid=…
# 같은 규칙을 차단으로 바꾼 뒤
code=000 err=Recv failure: Connection reset by peer
# Gateway 네트워크 로그 (UTC, 목적지 172.31.0.19:80)
07:49 allow rule="" ← 규칙 꺼짐
07:51 allow rule="" ← 규칙 켠 직후
07:55 allow rule=<uuid> ← 비공개 창(로그인 화면이 나온 요청)
07:57 block rule=<uuid> ← 차단으로 바꾼 뒤
이메일 일회용 코드로 WARP 에 로그인했습니다(첫 번째 인증). Success! 화면에서 Open Link 를 누르자 클라이언트가 Connected 가 됐습니다.
WARP 를 켜면 Microsoft 365 가 멀리 있는 다른 나라 서버로 붙습니다
WARP 를 켜면 Microsoft 365(M365) 이름을 Gateway 가 풀기 때문에, 켰을 때와 껐을 때 받는 IP 와 응답하는 Microsoft 서버가 달라지는 것은 두 OS 에서 확인했습니다. 다만 실습 PC 와 Cloudflare 접속 위치가 모두 미국 동부라 다른 나라 서버로 붙는 증상까지는 재현하지 못했습니다.
Windows
부분 재현
켬·끔마다 받는 IP 가 다름. 응답 서버는 켬·끔 모두 북미
Linux
부분 재현
켬·끔마다 받는 IP 와 응답한 Microsoft 서버가 바뀜. 둘 다 북미 서버라 다른 나라로 붙지는 않음
지역 차이가 없는 실습 환경이라 대책의 효과는 검증하지 못했습니다.
고객 사례에서 거론된 대책은 M365 를 터널에서 빼는 것입니다. 단말 프로필의 Office 365 제외(Directly route Microsoft 365 traffic)를 켜면 Microsoft 대역 95줄이 저절로 붙어 터널 밖으로 나가는 것까지는 「Split Tunnel 반대로 적용」 항목에서 확인했습니다. 이 설정은 Split Tunnel 이 Exclude 모드일 때만 쓸 수 있습니다(공식 문서 기준, 실습에서는 확인하지 않음).
공식 문서에는 Split Tunnel 이 IP 흐름만 바꾸고, DNS 는 Local Domain Fallback(지정한 도메인을 PC 원래 DNS 로 풀게 하는 목록)에 넣지 않는 한 계속 Gateway 가 푼다고 적혀 있습니다. 이름을 푸는 위치까지 바꾸려면 이 설정도 함께 검토해야 합니다(실습에서는 확인하지 않음).
한 고객사의 해외 지사 사용자가 WARP 를 켜면 M365 가 멀리 있는 다른 나라 서버로 붙어 느려졌고, WARP 를 끄면 가까운 서버로 붙었습니다. 원인으로 「DNS 이름을 누가, 어디서 푸느냐」가 거론됐습니다.
Windows · Linux 실습 PC(미국 동부)에서 WARP 를 켠 채와 끈 채로 outlook.office365.com · login.microsoftonline.com 을 풀고, 그 IP 로 나가는 인터페이스와 Microsoft 응답 헤더 X-FEServer(응답한 프런트 서버 이름)를 봤습니다.
기본 Exclude 모드에서 한 번, 단말 프로필의 Split Tunnel(터널에 태울 목적지를 정하는 목록)을 Include 모드로 바꿔 M365 이름 4개만 넣고 한 번 더 했습니다.
Cloudflare 쪽 접속 위치는 /cdn-cgi/trace 의 colo 로 봤습니다. 두 PC 모두 IAD(미국 동부)였습니다.
WARP 를 켜면 DNS 질의를 WARP 가 받아 Gateway(Cloudflare 의 DNS 필터)가 DoH(HTTPS 로 보내는 DNS)로 풀고, 끄면 PC 원래 리졸버가 풀었습니다. 그래서 켬·끔마다 받는 IP 묶음이 달랐습니다. Windows 에서는 WARP 로컬 DNS 127.0.2.2 · 127.0.2.3 을 거쳤습니다.
Include 모드로 M365 이름만 터널에 넣어도 켰을 때 DNS 는 계속 Gateway 가 풀었습니다. 목록 밖인 www.cloudflare.com 은 warp=off(터널 밖)였습니다.
응답한 프런트 서버 이름은 요청마다 바뀌었고 켬·끔 모두 북미(namprd) 서버였습니다. 실습 PC 와 Cloudflare 접속 위치가 같은 지역이라 「다른 나라 서버로 붙는」 증상은 이 환경에서 재현할 수 없었습니다.
ECS(EDNS Client Subnet, DNS 질의에 사용자의 대략적인 위치를 실어 보내는 기능)가 WARP 단말의 질의에도 적용되는지는 공식 문서에서 확인하지 못해 시험하지 않았습니다.
# Windows — outlook.office365.com, WARP 켬/끔 (KST)
Exclude 켬 11:07:17 52.96.173.130 … if=CloudflareWARP X-FEServer: BN9PR03CA0742 colo=IAD warp=on
Exclude 끔 11:07:25 52.96.104.18 … if=Ethernet X-FEServer: BN9PR03CA0977 colo=IAD warp=off
Include 켬 11:09:00 52.96.165.178 … if=CloudflareWARP X-FEServer: MN2PR11CA0024 colo=IAD warp=off
Include 끔 11:09:07 52.96.109.242 … if=Ethernet X-FEServer: BL1PR13CA0402 colo=IAD warp=off
# colo·warp 는 www.cloudflare.com 의 trace — Include 에서는 목록 밖이라 warp=off
# Windows — login.microsoftonline.com: 켬 20.190.151.x / 끔 20.190.190.x · 40.126.62.x
# Linux — outlook.office365.com, Include 4개 (캡처 화면)
켬 40.99.128.114 x-feserver: BL1PR13CA0444
끔 52.96.109.162 x-feserver: MN0PR05CA0007
# 응답한 백엔드는 켬·끔 모두 namprd(북미)
X-CalculatedBETarget: CH3PR03MB7460.namprd03.PROD.OUTLOOK.COM
X-CalculatedBETarget: DS4PPFE12D62847.namprd11.PROD.OUTLOOK.COM
Include 에 M365 이름 4개를 넣은 채 WARP 를 켜고 끄며 outlook.office365.com 을 풀었습니다. 켰을 때는 40.99.128.114 · BL1PR13CA0444, 껐을 때는 52.96.109.162 · MN0PR05CA0007 로 IP 와 응답 서버가 달랐지만, 둘 다 북미 서버였습니다.
목적지 조건을 0.0.0.0/0 으로만 적은 규칙에 IPv6 트래픽이 걸리지 않습니다
목적지 조건을 0.0.0.0/0 으로만 적은 Gateway 규칙은 IPv4 세션만 잡았고, 같은 사이트로 간 IPv6 세션은 놓쳤습니다. 조건에 ::/0 을 함께 넣자 IPv6 도 걸렸습니다. 실습 조직에는 egress 정책이 없어 같은 조건의 네트워크 규칙으로 확인했습니다.
Windows
부분 재현
egress 정책 대신 같은 조건의 네트워크 규칙으로 확인 — 규칙을 켜자 IPv4 세션은 이 규칙에 걸리고 IPv6 세션은 두 번 모두 규칙 id 가 빈 값. ::/0 을 더한 뒤 IPv6 도 걸림(한 번은 로그에 잡히지 않음)
Linux
부분 재현
egress 정책 대신 같은 조건의 네트워크 규칙으로 확인 — 규칙을 켜고 두 번 잰 IPv4 세션은 모두 걸리고 IPv6 세션은 모두 빈 값. ::/0 을 더한 뒤 두 번 모두 IPv6 도 걸림
목적지 조건에 IPv6 전체도 함께 넣습니다(net.dst.ip in {0.0.0.0/0 ::/0}). 실습 PC 두 대에서 IPv6 세션이 이 규칙에 걸리는 것을 확인했습니다.
바꾸기 전에 Gateway 네트워크 로그에서 IPv6 세션이 실제로 있는지, 어느 규칙에 걸렸는지 먼저 봅니다(GraphQL gatewayL4SessionsAdaptiveGroups 의 ruleId). 표본으로 모으는 로그라서 세션 일부가 빠질 수 있습니다.
egress 정책이라면 공식 문서는 목록 맨 아래에 나머지 트래픽을 모두 잡는 catch-all 정책을 두라고 권합니다. 이 정책에 걸린 트래픽은 전용 IP 가 아니라 Cloudflare 기본 출구로 나갑니다(실습에서는 확인하지 않음).
한 고객사가 그 회사 전용 출구 IP(전용 egress IP)를 고르는 egress 정책을 켜 두었습니다. 그런데 로그를 보니 IPv6 로 나간 세션은 모두 어느 정책에도 걸리지 않았습니다(egress 규칙 id 가 0 으로만 채운 값). 켜진 정책 2개의 목적지 조건은 모두 Destination IP in 0.0.0.0/0 이었습니다. 공식 문서에는 「0.0.0.0/0 에 IPv6 는 들어가지 않는다」는 문장이 없습니다.
실습 조직은 무료 요금제라 전용 egress IP 와 egress 정책이 없습니다. 그래서 같은 목적지 조건(net.dst.ip in {0.0.0.0/0})으로 네트워크 규칙(IP·포트 단위로 거르는 Gateway 규칙)을 허용(allow)으로 만들어 실습 PC 에만 걸리게 했습니다.
Windows · Linux 실습 PC 에서 WARP 를 켠 채 같은 사이트 www.cloudflare.com 에 IPv4(curl -4)와 IPv6(curl -6)로 한 번씩 붙었습니다.
규칙을 끈 상태, 켠 상태, 조건에 ::/0 을 더한 상태에서 되풀이하고, Gateway 네트워크 로그(세션마다 어느 규칙에 걸렸는지 남는 기록)에서 규칙 id 를 봤습니다. 허용 규칙이라 통신 결과는 세 상태 모두 같았고(HTTP 200), 로그의 규칙 id 로만 판정했습니다.
조건이 0.0.0.0/0 뿐일 때 IPv4 세션은 이 규칙에 걸렸지만, 같은 사이트로 간 IPv6 세션은 두 PC 모두 규칙 id 가 빈 값이었습니다. 어느 규칙에도 걸리지 않았다는 뜻입니다.
조건을 {0.0.0.0/0 ::/0} 으로 바꾸자 IPv6 세션도 이 규칙에 걸렸습니다.
실습 PC 는 바깥 네트워크에 IPv6 가 없습니다. 그래도 WARP 를 켜면 curl -6 가 터널 안 IPv6 로 IPv6 주소에 붙었습니다.
egress 정책에서도 똑같이 동작하는지, 걸리지 않은 트래픽이 가장 가까운 전용 IP 위치로 나가는지는 실습에서 확인하지 못했습니다. 공식 문서에는 그렇게 나간다고 적혀 있습니다. 걸리지 않은 세션의 규칙 id 표기도 실습 조직은 빈 값, 고객사는 0 으로만 채운 값으로 달랐습니다. 왜 다른지는 확인하지 못했습니다.
# 같은 사이트 www.cloudflare.com 에 curl -4 · curl -6 — Gateway 네트워크 로그의 ruleId (시각은 한국 시간)
조건 시각 PC IPv4 IPv6
(규칙 꺼짐) 16:06 Linux "" ""
{0.0.0.0/0} 16:08 Linux <uuid> ""
{0.0.0.0/0} 16:08 Windows <uuid> "" (2건)
{0.0.0.0/0 ::/0} 16:12 Linux <uuid> <uuid>
{0.0.0.0/0 ::/0} 16:13 Windows <uuid> <uuid>
# curl 결과는 세 상태 모두 같음(허용 규칙이라 통신은 그대로) — Linux
curl -4 … REMOTE=104.16.124.96 CODE=200
curl -6 … REMOTE=2606:4700::6810:7b60 CODE=200
조건이 0.0.0.0/0 뿐일 때의 Gateway 네트워크 로그입니다(16:08~16:10, SNI www.cloudflare.com). IPv4(104.16.x) 세션은 Policy name 에 실습 규칙이 찍히고, 같은 사이트로 간 IPv6(2606:4700::) 세션은 -- 로 어느 규칙에도 걸리지 않았습니다.
대시보드에서 클라이언트를 옛 버전으로 내리거나 고정할 수 있나요?
버전 지정(Client version assignments)은 Windows·macOS 에서, 2026.6 이상 버전끼리만 됩니다. 그 안에서는 올리기와 내리기 모두 약 3분 만에 끝났습니다(알림 20~33초 → 2분 대기 → 설치 26~30초). 2025.x 처럼 더 옛 클라이언트는 지정을 보지 않아 설치 파일로 직접 올려야 했습니다.
Windows
조건부
2026.6 보다 옛 버전은 지정할 수 없음(API 가 거부). 2026.6 이상 버전끼리는 내리기·올리기 모두 약 3분 만에 끝남
Linux
안 된다
Linux 는 버전 지정 대상이 아님(API 가 거부). 옛 버전은 설치 파일(deb)을 받아 직접 내리고 올림
2026.6 보다 옛 단말에는 대시보드 버전 지정이 먹지 않습니다. 공식 설치 파일을 위에 덮어 깔아 올립니다. 실습 PC 에서 등록과 MDM 설정이 유지되는 것까지 확인했습니다.
Linux 는 버전을 지정하는 기능이 없어 패키지 관리자(apt)로 버전을 바꿉니다. 저장소 목록에는 최신 한 버전만 보입니다. 옛 deb 파일은 저장소의 pool 주소에서 받을 수 있었습니다.
먼저 제거하고 다시 깔면 MDM 설정 파일도 함께 지워졌습니다(Windows·Linux 모두). 옛 버전을 이렇게 깔 때는 MDM 파일을 다시 넣어야 합니다.
배포 그룹의 대상 프로필은 비울 수 없었습니다(policy_ids cannot be an empty array). 더 쓰지 않을 그룹은 지우거나 지금 쓰는 버전으로 지정해 둡니다. 프로토콜은 비워 두지 말고 MASQUE 로 정해 둡니다(「26%에서 멈춤」 항목 참고).
관리자가 대시보드(또는 API)에서 단말 프로필마다 클라이언트 버전을 정해 두면, 단말이 그 버전으로 스스로 올리거나 내리는 기능입니다(배포 그룹). 한 고객사의 설정을 점검하다가 「콘솔로는 옛 버전으로 못 내린다」·「자동 업데이트를 끈 옛 단말이 남아 있다」·「업그레이드하면 터널 프로토콜이 바뀐다」·「API 목록과 상세가 다른 값을 준다」는 이야기가 나왔습니다. 이 네 가지를 실습 PC 로 확인했습니다.
API 로 지정할 수 있는 버전 목록을 조회했습니다. 그리고 Windows 단말에 2025.2.600.0, 2026.7.1343.0, 2026.7.1376.0 을 차례로 지정했습니다. Windows 실습 PC 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
Linux 단말에도 배포 그룹을 만들어 봤습니다.
옛 2025.2.600.0 을 공식 설치 파일(Windows MSI, Linux deb)로 직접 깔았습니다. 그 뒤 지정이 먹는지 보고, 최신으로 덮어 올린 뒤의 상태를 봤습니다.
프로필의 터널 프로토콜은 비워 두었습니다. 이 상태에서 옛·새 버전이 어떤 프로토콜로 붙는지 봤고, API 목록과 상세가 주는 값을 비교했습니다.
2026.6 보다 옛 버전은 지정 목록에 없었습니다. 지정하면 API 가 minimum version is 2026.6 오류로 거부했습니다. Linux 는 아예 지정 대상이 아니었습니다(target_environment must be macos or windows).
2026.6 이상 버전끼리는 내리기(1376→1343)와 올리기가 모두 됐습니다. 지정하고 20~33초 뒤 단말이 알림을 받았고, 2분 뒤 설치를 시작해 26~30초 만에 끝냈습니다. 설치하는 동안 창에 Update in progress 가 떴습니다. 연결은 끊기지 않았고 단말 등록도 바뀌지 않았습니다.
직접 깐 2025.2 는 지정을 보지 않았습니다. 11분 동안 설치를 시도하지 않았고, 설정에 Version Config 줄도 없었습니다. 그 위에 새 버전 설치 파일을 덮어 깔자 등록과 MDM 설정은 그대로 남았습니다.
프로토콜을 비워 두면 옛 버전과 새 버전 모두 WireGuard 로 붙었습니다. 그래서 업그레이드 때문에 프로토콜이 바뀌지는 않았습니다. API 목록과 상세도 둘 다 빈 값("")을 줬습니다.
# Windows — 2026.6 보다 옛 버전을 지정했을 때
400 {"code":2020,"message":"invalid version parameter: version 2025.2.600.0 is not supported; minimum version is 2026.6"}
# Linux 단말 대상으로 배포 그룹을 만들 때
400 {"code":2905,"message":"invalid client version target environment: target_environment must be macos or windows"}
# Windows 단말 — 1343 을 지정하고 20초 뒤 알림, 2분 뒤 설치 (시각은 UTC)
User Settings Changed: {version_config: null->{"target_client_version":"2026.7.1343.0",…}, allow_managed_deployments: false->true
Rescheduling evaluation timer evaluate_at=2026-09-23T21:51:29
MsiInstaller 11707 Product: Cloudflare One Client -- Installation completed successfully.
# Windows 단말 — 1376 으로 다시 올릴 때 (시각은 UTC)
22:16:56 지정 → 22:17:29 알림 → 22:19:29 설치 평가 → 22:19:35 MSI 시작 → 22:20:05 새 버전 2026.7.1376.0
# Linux — 저장소 목록에는 최신 한 버전, 옛 파일은 pool 주소에서 받아짐
apt-cache madison cloudflare-warp → 2026.7.1377.0 한 줄
pool/noble/main/c/cloudflare-warp/cloudflare-warp_2025.2.600.0_amd64.deb → HTTP/2 200
# 배포 그룹의 대상을 비우려 할 때
400 {"code":2902,"message":"invalid deployment group request: policy_ids cannot be an empty array"}
배포 그룹으로 2026.7.1376.0 을 지정하고 약 2분 40초 뒤, 창 위에 Update in progress 배너가 떴습니다. 이때 아래쪽 버전은 아직 2026.7.1343.0 입니다.
no_auth_no_internet 을 켜면 로그인하기 전에는 인터넷이 정말 막히나요?
MDM 파일을 공식 예시처럼 configs 배열 형식으로 쓰면, 등록이 없는 동안 바깥 인터넷이 막히고 클라이언트 내장 창으로 로그인하자 풀렸습니다. 최상위에 organization 을 두는 단일 형식에 넣으면 오류 없이 무시됐습니다.
Windows
조건부
MDM 파일을 configs 배열 형식으로 쓸 때만 켜짐. 등록이 없으면 웹·443 이 막히고(사설 주소 DNS 는 열림), 로그인 뒤 Open 을 누르면 6초 뒤 Connected. 단일 형식으로 쓰면 오류 없이 무시됨
MDM 파일은 configs 배열 형식으로 씁니다. 켜졌는지는 서비스 로그의 Firewall allow webview allow=true 줄로 확인할 수 있었습니다(단일 형식에서는 allow=false).
로그인 뒤 창 안의 Open 을 1분 안에 누르도록 안내합니다. 늦으면 버튼이 Authentication Expired 로 바뀌어 다시 로그인해야 합니다(「인증 만료와 세션 유지」 항목 참고).
원격 관리 도구(SSM·RMM 등)도 인증 전에는 막힙니다. 원격으로만 다루는 PC 는 MDM 을 되돌리는 예약 작업 같은 복구 수단을 먼저 둡니다.
잠금 화면 상태에서는 막지 않습니다. 등록 없이 재부팅한 직후의 동작과 no_auth_no_internet_block_rfc_1918(사설 주소까지 막기)은 아직 확인하지 않았습니다.
Windows 클라이언트(2026.5.0 이상, 베타)의 MDM 설정(관리 도구로 나눠 주는 클라이언트 설정 파일) no_auth_no_internet 입니다. 단말이 등록(로그인)되기 전에는 인터넷을 막고, 로그인은 기본 브라우저 대신 클라이언트 안의 WebView2(앱에 넣는 Edge 브라우저 엔진) 창으로 받습니다. 한 고객사가 「재부팅해도 세션이 남아 있으면 다시 로그인하라고 하지 않는다」고 물어 온 뒤 대책으로 거론된 기능이라 실습 PC 에서 확인했습니다.
Windows 실습 PC 에 토큰 없는 MDM 파일과 One-time PIN(메일로 받는 일회용 코드)으로 등록했습니다. 이 PC 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
키를 넣는 방식을 두 가지로 해 봤습니다. 최상위에 organization 을 두는 단일 형식과, 공식 예시의 configs 배열 형식입니다.
등록을 지운 뒤 2초마다 바깥 웹, 사설 주소(RFC1918)의 VPC DNS, 443 연결, 로그인 창을 기록했습니다. 원격 관리 통로도 같이 막히므로 단말 안 예약 작업으로 관찰하고, 정해진 시간 뒤 MDM 을 되돌리게 해 두었습니다.
내장 창에서 코드 입력 → Verify → Open 까지 해 봤습니다.
단일 형식에서는 키가 무시됐습니다. 적용된 설정이 no_auth_no_internet: false 로 남았고, 등록을 지워도 3분 내내 인터넷이 열려 있었으며 로그인 창도 뜨지 않았습니다. 오류 메시지는 없었습니다.
configs 형식에서는 등록이 없자 바로 막혔습니다. 바깥 웹과 443 연결은 실패했고, 사설 주소 DNS 는 됐습니다. 약 8초 뒤 브라우저가 아니라 Sign in · Cloudflare Access 라는 별도 창이 떴습니다.
내장 창에서 코드를 넣고 창 안의 Open 을 누르자 6초 뒤 Connected 가 되고 인터넷이 열렸습니다. Open 을 누르지 않은 차수는 5분 동안 막힌 채였습니다.
화면을 잠그면 5초 뒤 차단이 풀렸고, 잠금을 풀자 1초 만에 다시 막혔습니다. 문서의 「로그인해 있고 잠기지 않은 동안만 적용」과 같습니다.
# configs 형식 + 등록 없음 — 서비스 로그 (UTC)
00:22:57.397Z firewall::change: Changing catch all policy Deny->Deny
00:22:57.397Z firewall::change: Firewall allow webview allow=true
00:22:57.397Z firewall::change: Firewall allow RFC1918 subnets
# 2초마다 본 기록 — 웹·443 실패, 사설 주소 DNS 성공, 내장 창 프로세스 7개
[00:23:14Z] status=[Status update: Unable / Reason: Registration Missing due to: Daemon Startup] web=FAIL dns172=ok ssm443=err team443=err webview2=7
# 단일 형식에서는 같은 자리가
no_auth_no_internet: false
Firewall allow webview allow=false
Firewall disallow RFC1918 subnets
등록이 없어 인터넷이 막힌 동안, 로그인은 브라우저가 아니라 Sign in · Cloudflare Access 라는 클라이언트 내장 창(WebView2)에서 진행됐습니다. 메일로 받은 코드를 넣는 단계입니다.
Application check 는 지정한 프로그램이 돌고 있으면 통과하나요? 스크립트여도 되나요?
실행 파일일 때만 됩니다. Application check 는 지금 떠 있는 프로세스의 실행 파일 경로로 판정합니다. 셸 스크립트와 배치 파일은 실제로 도는 프로그램이 /bin/sh·cmd.exe 라서 세 OS 모두 running: false 였고, 실행 파일을 넣으면 running: true 였습니다.
Windows
조건부
실행 파일 labproc.exe 는 잡힘(running: true). 도는 중인 배치 파일 labproc.bat 은 못 잡음(running: false, 실제 프로세스는 cmd.exe)
macOS
조건부
셸 스크립트는 못 잡음(running: false). 직접 빌드한 실행 파일은 다음 검사(4분 23초 뒤)에 잡힘(running: true)
Linux
조건부
셸 스크립트는 못 잡음(running: false, 실제 프로그램은 dash). python3 복사본은 1분 안에 잡힘(running: true)
경로에는 스크립트나 그것을 돌리는 인터프리터가 아니라, 실제로 떠 있는 에이전트 자체의 실행 파일을 넣습니다(세 OS 에서 확인).
결과는 검사 주기만큼 늦게 반영됩니다. 바로 보려면 클라이언트 서비스를 다시 켠 뒤 warp-cli debug posture 로 확인합니다(Windows·Linux 에서 확인).
실습용으로 시스템 실행 파일을 복사해 쓸 수 없는 OS 가 있었습니다. macOS 26 은 /bin/sleep 복사본을 커널이 바로 죽였고(Launch Constraint Violation), Ubuntu 26.04 는 복사본이 coreutils: unknown program 으로 끝났습니다. 직접 빌드한 실행 파일이나 python3 같은 단독 실행 파일을 씁니다.
Application check 는 단말 posture(기기 상태 검사) 가운데 하나입니다. 지정한 경로의 프로그램이 지금 돌고 있는지 클라이언트가 주기적으로 확인해 올립니다. 실습 조직에 «스크립트를 지정하면 잡히지 않는다»는 기록이 있어 세 OS 에서 다시 확인했습니다. 파일이 있는지 보는 File check 도 함께 봤습니다.
검사 경로(/usr/local/bin/labproc)에 셸 스크립트를 두고 돌린 뒤, 같은 경로를 진짜 실행 파일로 바꿔 돌렸습니다.
Windows 는 ping.exe 를 복사한 C:\wfl\labproc.exe 와, 배치 파일 C:\wfl\labproc.bat 을 보는 검사를 하나 더 만들어 나란히 봤습니다. 이 PC 는 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
판정은 단말에서 warp-cli debug posture(클라이언트가 마지막으로 잰 결과)로 읽었습니다.
표식 파일을 지웠다가 다시 만들며 File check 결과를 봤습니다.
클라이언트는 떠 있는 프로세스의 실행 파일 경로로 판정했습니다. 스크립트는 명령줄에 경로가 보여도 실제 실행 파일이 /bin/sh(Windows 는 cmd.exe)라서 세 OS 모두 running: false 였습니다.
스크립트일 때도 sha256 은 스크립트 파일의 해시로 채워졌습니다. 파일은 찾았지만 «실행 중»으로 보지 않은 것이라, 해시가 보인다고 통과한 것이 아닙니다.
실행 파일로 바꾸자 모두 running: true 가 됐습니다. Windows 는 서명 지문(thumbprint)까지 채웠습니다.
File check 는 세 OS 모두 파일을 지우면 exists: false, 다시 만들면 exists: true 였습니다. 5분 주기에서 macOS 는 반영까지 142~293초 걸렸습니다.
# Windows — 두 검사를 한 번에 (warp-cli debug posture)
Name: wfl-application-check-windows
path: C:\wfl\labproc.exe
running: true
Name: wfl-application-check-windows-script
path: C:\wfl\labproc.bat
running: false
# macOS — /bin/sleep 을 복사해 돌렸을 때
kernel (AppleMobileFileIntegrity) AMFI: Launch Constraint Violation (enforcing) …
… line 3: 55738 Killed: 9 /usr/local/bin/labproc 1
같은 방식의 Application check 두 개를 한 화면에 띄웠습니다. ping.exe 를 복사한 C:\wfl\labproc.exe 는 running: true, 돌고 있는 배치 파일 C:\wfl\labproc.bat 은 running: false 였습니다(실제 프로세스는 cmd.exe).
회사 CA 가 발급한 클라이언트 인증서가 단말에 있는지를 기기 검사(posture)로 확인할 수 있나요?
무료 조직의 Linux 실습 PC 에서 됐습니다. 올린 CA 가 직접 서명한 인증서, 이름(CN), 개인키가 모두 맞을 때만 통과했지만, 새로 만든 규칙은 10분을 기다려도 단말에 내려오지 않아 WARP 서비스를 다시 시작해야 했습니다.
Linux
된다
올린 CA 가 서명한 CN wfl-linux 인증서와 키가 있으면 exists: true, 파일 없음·CN 다름·다른 CA 서명·키 없음은 모두 false. 새 규칙은 10분 동안 안 내려왔고 서비스 재시작 직후 평가됨
CA 는 클라이언트 인증서를 직접 서명한 인증서를 올립니다. 다른 CA 가 서명한 인증서는 실패함을 실습 PC 에서 확인했습니다. 문서도 신뢰 체인을 따라 올라가지 않는다고 적고 있습니다(루트 CA 만 올리는 경우는 해 보지 않았습니다).
새 규칙이 단말에 안 보이면 WARP 서비스를 다시 시작합니다(Linux 는 warp-svc 서비스, 곧바로 평가됨을 확인). 재시작 없이 언제 내려오는지는 확인하지 못했습니다.
통과하지 못하면 단말에서 인증서 이름·발급자, CA 검증, 키 파일을 따로 봅니다(openssl x509 -noout -subject -issuer, openssl verify -CAfile). 클라이언트는 이유를 not_found 하나로만 보여 줍니다.
Pulumi 로 관리하면 규칙 input 에 operatingSystem 을 넣지 말고(API 가 버려 늘 차이로 보임) OS 는 matches 로만 정하며, subjectAlternativeNames: [] 를 적어야 preview --refresh 가 0 차이였습니다.
Client certificate 검사는 단말에 회사 CA(인증서를 발급하는 기관 인증서)가 서명한 클라이언트 인증서가 있는지 보고 회사 기기인지 가리는 기기 검사(posture)입니다. 서명한 CA 의 공개 인증서를 계정에 mTLS 인증서로 올리고, 검사 규칙에 그 인증서, 인증서 이름(CN), 개인키 확인 여부, 인증서 위치를 적습니다. 문서에는 모든 요금제에서 쓸 수 있다고 적혀 있습니다.
Linux 실습 PC 에서 openssl 로 시험용 CA 와, 그 CA 가 서명한 클라이언트 인증서(CN wfl-linux)를 만들어 인증서·키 파일을 정해 둔 경로에 두었습니다. 무료 요금제 실습 조직에 CA 는 공개 인증서만 올렸습니다(ca: true).
검사 규칙(client_certificate_v2, 5분 주기, 개인키 확인 켬, 인증서·키 파일 경로 지정, Linux 만 대상)을 만들고, 단말에서 warp-cli debug posture 로 10초마다 10분 동안 결과를 찾았습니다.
그다음 서비스를 다시 시작할 때마다 인증서 파일 없음, CN 다름, 다른 CA 가 서명, 개인키 없음 네 가지를 차례로 만들어 비교했습니다.
Windows(로컬 컴퓨터 인증서 저장소)는 시간이 모자라 하지 못했습니다.
무료 조직에서도 CA 올리기와 검사 규칙 만들기가 거절 없이 됐습니다.
새 규칙은 10분 동안 단말의 debug posture 에 나타나지 않았습니다. WARP 서비스(warp-svc)를 다시 시작하자 2초 뒤 연결되고 곧바로 평가돼 exists: true 가 나왔습니다. 같은 날 프로필 값을 바꿨을 때는 14~52초 만에 내려왔습니다(「프로필 겹치면 한쪽만」 항목).
네 가지 실패 조건은 모두 exists: false 였고, 이유와 관계없이 오류는 not_found 하나였습니다. 어느 조건에서 떨어졌는지는 클라이언트 결과만으로 알 수 없습니다.
올린 CA 는 Gateway 인증서 목록에도 custom · in_use: false 로 보였고, TLS 복호화에는 쓰이지 않았습니다.
# 규칙을 만들고 10분(KST 19:07~19:17) 동안 debug posture 에 없음
# warp-svc 서비스를 다시 시작한 직후
Type: ClientCertificateV2
Results:
certificate_id: <uuid>
exists: true
error: null
# 대조 — 인증서 파일 없음 / CN 다름 / 다른 CA 가 서명 / 개인키 없음
exists: false
error: not_found (네 가지 모두 같음)
올린 CA 가 직접 서명한 인증서(CN wfl-linux)와 개인키가 지정한 경로에 있으면 검사 결과가 exists: true 입니다. 아래 두 줄은 같은 인증서를 openssl 로 본 이름(subject)과 발급자(issuer)입니다.
클라이언트 인증서 발급을 켜지 못한 조직에서는 Posture only 모드가 등록 방식과 관계없이 Device identity not found 로 멈췄습니다
존이 없어 인증서 발급을 켤 수 없는 실습 조직에서, 세 OS 모두 클라이언트가 단말 인증서를 받는 단계에서 7453 Invalid CSR 로 거절돼 Posture only 가 시작되지 않았습니다. 사람이 로그인해 등록한 단말(Linux·Windows)도 같았고, 같은 서비스 토큰으로 로컬 프록시 모드는 1초 안에 연결됐습니다. 인증서 발급을 켜지 못한 것이 원인인지는 확인하지 못했습니다.
Windows
안 된다
토큰 등록은 1.8초 뒤 Failed to set mTLS Identity(7453)로 멈춤. 사람 등록·프로필로 켜도 같음. 같은 토큰의 proxy 는 1초 뒤 Connected
macOS
안 된다
토큰 등록에서 1초 만에 Unable, 약 90초 동안 7453 줄이 85번. 프로필로 켜도 같음. 같은 토큰의 proxy 는 곧바로 Connected
Linux
안 된다
토큰(MDM·프로필)·사람 등록 모두 1초 뒤 Unable, 사람 등록은 60초 동안 7453 줄이 64번. 같은 토큰의 proxy 는 1초 뒤 Connected
Posture only 를 쓰려면 문서대로 존 단위 클라이언트 인증서 발급을 먼저 켭니다. 켠 조직에서 정상 동작하는지는 이 실습에서 확인하지 못했습니다.
창에 Device identity not found 가 뜨면 서비스 로그에서 dp_signature 요청의 7453 Invalid CSR 줄을 찾아봅니다. 세 OS 모두 같은 줄이 찍혔습니다.
서비스 토큰 단말에서 터널 없이 등록과 posture 만 유지하면 된다면 로컬 프록시 모드(proxy)는 세 OS 모두 1초 안에 Connected 였습니다(실습 PC 에서 확인). 단, 127.0.0.1:40000 프록시로 보낸 요청만 Cloudflare 를 거칩니다.
Posture only 모드는 트래픽을 터널로 보내지 않고 단말 posture(기기 상태 검사) 결과만 올리는 클라이언트 모드입니다. 문서에 따르면 이 모드는 클라이언트 인증서로 단말을 확인하므로, 계정에서 존(도메인) 단위 클라이언트 인증서 발급을 먼저 켜야 합니다. 실습 조직에서 서비스 토큰(사람 없이 단말을 등록하는 자격)으로 등록한 단말을 이 모드로 두자 7453 Invalid CSR 가 난 기록이 있어 세 OS 에서 다시 확인했습니다.
서비스 토큰으로 등록한 세 OS 실습 PC 에서 MDM(관리 도구로 나눠 주는 클라이언트 설정 파일)의 service_mode 만 postureonly 로 바꿨습니다. 대시보드 프로필 쪽 posture_only 로도 켜 봤습니다.
Linux·Windows 는 서비스 토큰 없이 사람이 One-time PIN(메일로 받는 일회용 코드)으로 등록한 단말에서도 같은 설정을 해 봤습니다.
대조로 같은 토큰·같은 등록에서 service_mode 만 proxy(포트 40000)로 바꿨습니다.
실습 조직은 존이 0개라 문서가 요구하는 인증서 발급은 켤 수 없었습니다. Windows PC 는 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
세 OS 모두 1~2초 만에 Status update: Unable / Failed to set mTLS Identity 가 됐고, 로그에는 인증서 요청(dp_signature)이 7453 Invalid CSR 로 거절된 줄이 되풀이됐습니다. 창에는 Device identity not found · CF_FAILED_TO_SET_MTLS 가 떴습니다.
MDM 으로 켜든 프로필로 켜든, 서비스 토큰이든 사람 등록이든 결과가 같았습니다. 서비스 토큰 탓은 아니었습니다.
이 동안 등록은 그대로였고 트래픽은 터널 없이 나갔습니다(warp=off). Linux·macOS 에서는 posture 검사도 계속 돌았습니다.
Linux 에만 NSS(리눅스 인증서 저장소) 초기화 실패 줄이 함께 찍혔지만, 저장소를 만들어 줘도 그대로였고 NSS 를 쓰지 않는 macOS 도 7453 이었습니다. 인증서 발급을 켜지 못한 것이 원인인지는 확인하지 못했습니다.
# macOS — service_mode=postureonly 로 바꾼 뒤 상태
+1s Status update: Unable Reason: Failed to set mTLS Identity
# 서비스 로그 (macOS, Linux·Windows 도 같은 7453)
… Starting Warp Connection operation_mode=PostureOnly … posture_only=enabled forward_proxy=disabled
… warp_connection::dp_only: Generating new device-posture-only identity
… request=dp_signature … result=Err(ApiError { status_code: 400, message: Some(WarpApiErrorMessage { code: 7453, message: Some("Invalid CSR") }) })
… Connection error error=UnableToEnsureMtlsIdentity(…)
# 대조: 같은 토큰으로 proxy 모드
+0s Status update: Connected Network: healthy
… operation_mode=WarpProxy on port 40000 … posture_only=disabled forward_proxy=enabled
서비스 토큰으로 등록한 단말의 MDM 에서 service_mode 만 postureonly 로 바꾸자 Device identity not found 와 오류 코드 CF_FAILED_TO_SET_MTLS 가 떴습니다. 왼쪽 메뉴에서 Connectivity 가 사라졌습니다.
MDM 으로 배포한 단말의 GUI 에는 진단 내보내기 메뉴가 없습니다. 진단 zip 은 어떻게 받나요?
세 OS 모두 GUI 로는 진단을 내보낼 수 없었습니다. 대시보드의 DEX 원격 캡처로 요청한 warp-diag 는 5~38초 만에 끝났고, 단말에서 직접 받은 것과 파일 목록이 같았습니다. 무료 요금제 조직에서도 됐습니다.
Windows
된다
DEX 원격 캡처로 38초 만에 받음(파일 140개, 단말에서 받은 것과 같음). 추가 로깅은 WFP·pktmon 캡처까지 켜고 10분 뒤 꺼짐
macOS
된다
DEX 원격 캡처로 14초 만에 받음(파일 183개, 단말에서 받은 것과 같음). 추가 로깅 10분 동안 qlog 파일이 11.36 GB
Linux
된다
DEX 원격 캡처로 5초 만에 받음(파일 112개, 단말에서 받은 것과 같음). 추가 로깅은 정확히 10분 뒤 꺼짐
Windows 에서 추가 로깅은 WFP 캡처·TCP/IP 추적·패킷 캡처(pktmon)까지 켭니다. 켠 채로 서비스를 다시 시작하면 OS 캡처가 남아 다음 start 가 some features could not be enabled 로 일부 실패했습니다. netsh wfp capture status·pktmon status 로 확인합니다. 10분 자동 종료가 남은 캡처까지 끄는 것은 확인했고, stop 으로 끄는 것은 해 보지 않았습니다(Windows Server 2025 실습 PC 기준).
macOS 에서는 추가 로깅이 켠 qlog(터널 기록 파일)가 10분 만에 11.36 GB 가 됐습니다. 그동안 이 단말은 터널로 수십 GB 를 받고 있었습니다. 끝나면 /Library/Application Support/Cloudflare/qlogs/ 를 확인해 지웁니다.
macOS 에서 root 로 warp-diag 를 돌리면 기본 저장 위치 /var/root/Desktop 폴더가 없어서, 그 이름의 zip 파일이 생겼습니다. 스크립트나 원격 도구로 돌릴 때는 --output 을 줍니다(이 옵션으로 다시 돌려 보지는 않았습니다).
MDM 배포 단말의 사용자에게 GUI 로 진단을 받아 달라고 할 수는 없습니다. 원격 단말은 DEX 원격 캡처로 받고, 받을 때는 목록 응답의 filename 주소를 그대로 씁니다.
클라이언트 문제를 지원팀에 넘길 때는 진단 zip(warp-diag)이 필요합니다. 받는 길은 세 가지입니다. 단말에서 명령을 직접 돌리기, warp-cli debug extra start 로 잠시 로그를 더 자세히 남기기(추가 로깅), 대시보드의 DEX 원격 캡처(DEX: 관리자가 대시보드에서 단말 상태를 보고 진단 수집을 시키는 기능)입니다. MDM(기기 관리 도구로 설정을 밀어 넣는 배포)으로 깐 단말에서 GUI 로도 받을 수 있는지 함께 봤습니다.
세 OS 실습 PC 를 서비스 토큰 MDM 으로 등록해 두고, API(POST /dex/commands)로 warp-diag 원격 캡처를 요청했습니다. 받은 zip 의 파일 이름을 단말에서 직접 돌린 결과와 대조했습니다(내용은 열지 않았습니다).
warp-cli debug extra start 를 켜고, 꺼지는 시각과 로그를 지켜봤습니다.
토큰 MDM 과 토큰 없는 MDM(사람이 로그인해 등록)에서 트레이 메뉴·앱 메뉴·창 화면을 비교했습니다. macOS 는 토큰 MDM 쪽만 확인했습니다.
DEX 원격 캡처는 무료 요금제 조직에서도 됐습니다(하루 100번). 받은 zip 의 파일 목록은 세 OS 모두 단말에서 직접 받은 것과 같았습니다(Linux 112개·Windows 140개·macOS 183개). MDM 설정 원본은 없고 가린 판(local_policy.redacted.txt)만 들어 있었습니다.
API 문서의 zip 받기 주소(/dex/commands/{id}/downloads/…)는 404 였습니다. 목록 응답의 filename 주소(/dex/devices/{device_id}/commands/downloads/…)로는 받아졌습니다(Linux·macOS 에서 확인).
추가 로깅은 세 OS 모두 정확히 10분 뒤 스스로 꺼졌습니다. 하위 명령은 start·stop·reconnect 뿐이라 켜져 있는지 보는 명령은 없습니다.
MDM 배포 단말의 GUI 에는 진단 내보내기·환경설정 메뉴가 없었습니다. 트레이 메뉴는 토큰 등록이면 Pause, 로그인 등록이면 Disconnect 였고, 로그인 등록일 때만 Profile 에 Re-authenticate 버튼이 생겼습니다.
# Windows: 추가 로깅을 켠 채 서비스를 다시 시작한 뒤 두 번째 start
Error: Extra logging has started, but some features could not be enabled.
Failed to start WFP capture err=netsh wfp capture start exited with status exit code: 1
Failed to start TCPIP trace err=netsh trace start exited with status exit code: 1
> netsh wfp capture status
Capture is already in progress.
# macOS: 정확히 10분 뒤 스스로 꺼짐
01:03:06.095Z DEBUG warp_edge::h3_tun: Enabled dynamic qlog
01:13:06.068Z DEBUG warp_edge::h3_tun: Disabled dynamic qlog
서비스 토큰 MDM 으로 배포한 단말의 트레이 아이콘을 오른쪽 클릭하면 Pause 한 줄만 나옵니다. 진단 내보내기·환경설정 메뉴는 없습니다.
DEX 합성 테스트(HTTP·traceroute)를 일부 단말에만 걸면 클라이언트가 받아서 돌리나요?
무료 요금제 조직에서도 됐습니다. 세 OS 클라이언트가 테스트를 만든 지 25~38초 만에 받아 돌리고 결과를 올렸습니다. 다만 대상은 단말 프로필이 아니라 DEX 규칙으로만 고를 수 있었고(프로필 id 는 404), HTTP 테스트 결과(가져오기 시간 평균 157ms, 가용성 100%)는 대시보드에 나왔지만, traceroute 는 단말 로그에 결과를 올렸다고 찍혔는데도 대시보드가 No data 였습니다.
Windows
조건부
DEX 규칙으로 걸자 만든 지 38초 뒤 받음. HTTP 결과는 약 47초, traceroute 결과는 약 68초에 올림(세 OS 중 가장 늦음). 끈 지 20초 뒤 목록에서 빠짐. 대시보드에 HTTP 결과는 나오고(세 대 합산) traceroute 는 No data
macOS
조건부
DEX 규칙으로 걸자 만든 지 25초 뒤 받음. HTTP 결과는 약 33초, traceroute 결과는 약 45초에 올림. 끈 지 7초 뒤 목록에서 빠짐. 대시보드에 HTTP 결과는 나오고(세 대 합산) traceroute 는 No data
Linux
조건부
DEX 규칙으로 걸자 만든 지 26초 뒤 받음. HTTP 결과는 약 33초, traceroute 결과는 약 47초에 올림. 끄자 20초 안에 목록에서 빠짐. 대시보드에 HTTP 결과는 나오고(세 대 합산) traceroute 는 No data
일부 단말에만 걸려면 DEX 규칙을 먼저 만들어 테스트 대상으로 붙입니다(API target_policies 에 규칙 id 를 넣음, 실습 PC 에서 확인). 규칙을 붙이지 않으면 조직의 모든 단말에서 돈다고 공식 문서에 나와 있습니다. 이 경우는 실습에서 해 보지 않았습니다.
API 나 Pulumi 로 만들 때는 주기를 "0h5m0s" 처럼 시·분·초로 씁니다(확인함).
서비스 토큰으로 등록한 단말은 DEX 규칙에서 identity.email == "non_identity@<팀 이름>.cloudflareaccess.com" 조건으로 잡혔습니다(세 단말 모두 테스트를 받음, 확인함).
결과는 대시보드 Digital experience › Tests 에서 봅니다(HTTP 는 확인함). traceroute 가 No data 로 나오면 단말 DEX 로그의 결과 올림 줄로 테스트가 돌았는지부터 봅니다. 결과를 API 로 읽는 파라미터 조합(from·to·interval)은 이번에 찾지 못했습니다.
DEX(Digital Experience Monitoring: 사용자 단말의 연결 품질을 지켜보는 기능)의 합성 테스트를 켜면, 관리자가 정한 주소로 클라이언트가 주기적으로 HTTP 요청이나 traceroute(목적지까지 거치는 경로 추적)를 보내고 결과를 Cloudflare 로 올립니다. 공식 문서에 따르면 모든 요금제에서 쓸 수 있고, 주기는 5~60분이며, 대상을 고르지 않으면 조직의 모든 단말에서 돕니다. 실습 PC 세 대에만 걸 수 있는지, 클라이언트가 실제로 받아 돌리는지 확인했습니다.
무료 요금제 조직에서 HTTP 테스트(https://www.cloudflare.com GET)와 traceroute 테스트(1.1.1.1)를 5분 주기로 만들었습니다. 만들 때는 설정을 코드로 관리하는 도구인 Pulumi 를 썼습니다.
처음에 대상으로 세 PC 의 단말 프로필을 넣었는데 실패했습니다. 그래서 DEX 규칙(서비스 토큰으로 등록한 단말 가운데 세 PC 의 OS·버전만 고르는 조건식)을 만들어 대상으로 붙였습니다.
세 PC 의 클라이언트 DEX 로그(cfwarp_service_dex.txt)에서 테스트를 받은 시각과 결과를 올린 시각을 확인했습니다. 결과 API 로 값을 읽는 것도 시도했습니다.
마지막으로 두 테스트를 끄고(enabled: false) 단말의 테스트 목록에서 빠지는지 확인했습니다.
주기를 "5m" 로 주면 400 invalid interval string 오류가 났고, "0h5m0s" 형식으로 줘야 했습니다. 대상에 단말 프로필 id 를 넣으면 404 No rule found with id 오류가 났습니다. 대상 칸에는 DEX 규칙 id 만 넣을 수 있었습니다.
규칙으로 걸자 세 OS 모두 만든 지 25~38초 만에 두 테스트를 받았습니다. 받은 뒤 7~9초 만에 HTTP 결과를 올렸고(Successfully posted DEX test results.), traceroute 결과는 그보다 12~20초 늦게 올렸습니다. 끄자 7~20초 안에 테스트 목록에서 빠졌고, 그 뒤로는 결과 줄이 없었습니다.
세 단말에는 우리가 만들지 않은 Cloudflare Status 테스트(https://www.cloudflarestatus.com/, 5분 주기)가 처음부터 돌고 있었습니다. 이 테스트는 조직의 테스트 목록 API 에는 나오지 않았습니다.
결과 요약 API(/dex/tests/overview)에 두 테스트는 보였지만 결과 칸이 null 이었습니다. 테스트별 결과 API 는 400 오류를 돌려줬습니다. 대시보드(Digital experience › Tests)에서는 HTTP 테스트가 대상 단말 3대, 가져오기 시간 최소 112 · 평균 157 · 최대 234ms, 가용성 100% 로 나왔습니다. traceroute 테스트는 같은 화면에서 대상 단말 -, No data 였습니다(원인 미확인).
# API 로 만들 때 실패한 두 번
interval: "5m" → 400 {"code":11005,"message":"dex.api.parameter.invalid"} invalid interval string
target_policies: [프로필 id] → 404 dex.api.resource.not_found No rule found with id '<uuid>'
# Linux DEX 로그 (UTC) — 규칙으로 건 지 26초 뒤
10:02:07.162Z INFO dex: Spawning DEX task … test_id: <uuid>, test_info: Http { method: GET, host: https://www.cloudflare.com/, …
10:02:07.162Z INFO dex: Spawning DEX task … test_id: <uuid>, test_info: Traceroute { host: "1.1.1.1" }, …
10:02:14.025Z INFO dex: Successfully posted DEX test results. test_ids=[<uuid>, <uuid>]
10:02:28.452Z INFO dex: Successfully posted DEX test results. test_ids=[<uuid>]
# 결과 값 조회
GET /dex/http-tests/{id}?from=…&to=…&interval=minute → 400 {"code":11008,"message":"dex.api.filter.invalid"}
클라이언트 DEX 로그에 두 테스트의 결과를 큐에 넣고(Adding new DEX test result to queue) Cloudflare 로 올린(Successfully posted DEX test results) 줄이 남았습니다. 이 구간에는 다른 시험 때문에 서비스를 여러 번 다시 시작했는데, 다시 시작할 때마다 결과 줄이 새로 찍혔습니다. 시각은 UTC 입니다.
사무실에 둔 TLS 비콘으로 프로필이 저절로 바뀌고, 비콘이 꺼지면 저절로 돌아오나요?
비콘을 등록하자 세 OS 모두 10~70초 만에 사무실 프로필로 바뀌었습니다. 그러나 비콘을 꺼도, 끊었다 다시 연결해도 사무실 프로필이 풀리지 않았고, 클라이언트 서비스를 다시 시작해야 2~3초 만에 원래 프로필로 돌아왔습니다.
Windows
조건부
사무실 프로필을 만들고 약 25초 뒤 전환. 비콘을 끄고 150초 지켜본 뒤와 재연결 뒤에도 그대로였고, 서비스 재시작 2.9초 만에 원래 프로필
macOS
조건부
등록하고 약 70초 뒤(설정 변경 알림을 받고 1초 안) 전환. 비콘을 끄고 5분 넘게, 재연결 뒤에도 그대로였고, 데몬 재시작 2초 만에 원래 프로필
Linux
조건부
등록하고 약 10초 뒤 전환. 비콘을 끄고 5분 넘게, 재연결 뒤에도 그대로였고, 서비스 재시작 3초 만에 원래 프로필
비콘 서버가 멈춰도 이미 사무실로 판정된 단말은 사무실 프로필을 유지합니다. 「비콘 장애 = 곧바로 원래 프로필」로 기대하지 않습니다.
곧바로 다시 판정시키려면 클라이언트 서비스(Linux warp-svc, Windows CloudflareWARP, macOS WARP 데몬)를 다시 시작합니다(세 OS 에서 2~3초로 확인). warp-cli disconnect/connect 로는 다시 판정하지 않았습니다.
실제로 사무실을 떠나 네트워크가 바뀔 때 원래 프로필로 돌아오는지는 이 실습에서 해 보지 않았습니다(미확인).
어느 관리 네트워크로 판정됐는지는 창보다 warp-cli debug alternate-network 로 봅니다(판정이 없으면 <None>). 관리 네트워크를 여러 개 등록하면 단말마다 그 수만큼 TLS 확인을 합니다.
「관리 네트워크(Managed networks)」는 사무실 안에 TLS 서버 하나(이하 비콘)를 두고, 클라이언트가 그 서버 인증서의 SHA-256 지문을 확인하면 「이 단말은 사무실 안에 있다」고 판정하는 기능입니다. 단말 프로필(단말에 내려가는 클라이언트 설정 묶음)의 조건에 network 를 넣으면, 사무실에서만 다른 설정(예: Split Tunnel 범위)을 쓰게 할 수 있습니다.
세 OS 실습 PC(클라이언트 2026.7)마다 자기 사설 IP 의 4443 포트에 자체 서명 인증서로 비콘을 띄웠습니다. Linux·macOS 는 openssl s_server 로, openssl 이 없는 Windows 는 PowerShell 로 만들었습니다. 실습 PC 끼리는 들어오는 연결이 막혀 있어 다른 PC 가 아닌 자기 자신에게 비콘을 두었습니다.
비콘 주소와 인증서 지문으로 관리 네트워크를 등록하고, 조건에 그 이름(network == "wfl-office-linux" 등)을 넣은 사무실 프로필을 만들었습니다.
5초마다 warp-cli debug alternate-network(감지된 관리 네트워크 이름)와 Profile ID 를 기록했습니다.
비콘을 끄고 5분(Windows 는 150초) 지켜본 뒤, warp-cli disconnect/connect, 서비스 재시작 순으로 해 봤습니다. Windows PC 는 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
등록 뒤 사무실 프로필로 바뀌기까지 Linux 약 10초, Windows 약 25초(관리 네트워크를 먼저 만들고 프로필을 나중에 만든 뒤), macOS 약 70초 걸렸습니다. macOS 는 서버의 설정 변경 알림(UpdateConfig)이 늦게 왔을 뿐, 알림을 받고 1초 안에 비콘을 찾았습니다. 연결은 전환 순간을 빼고 계속 Connected 였습니다.
비콘을 꺼도 Linux·macOS 는 5분 넘게, Windows 는 지켜본 150초 내내 사무실 프로필에 머물렀고, disconnect/connect 로도 돌아오지 않았습니다. 서비스를 다시 켜자 2~3초 만에 원래 프로필로 돌아왔습니다. 판정은 설정을 새로 받거나 서비스가 켜질 때 돌았고, 비콘이 사라진 것만으로는 다시 돌지 않았습니다.
판정은 인증서 지문만 맞춰 보고 곧바로 연결을 끊었습니다. 자체 서명 인증서로 충분했습니다. 또 단말은 조직에 등록된 모든 관리 네트워크(다른 단말용 포함)를 함께 확인했습니다.
Windows 창에는 감지된 관리 네트워크 이름이 나오지 않았습니다. Settings 의 Device profile 줄(프로필 id)로만 바뀐 것을 알 수 있었습니다.
# macOS 서비스 로그 — 설정 변경 알림을 받고 1초 안에 비콘 판정
WARN warp::notification: direct nof from api notif=Notification { id: "<uuid>", inner: Notification(UpdateConfig) }
DEBUG handle_command{command=RegistrationChanged(..)}: actor_alternate_network::actor: Registration updated, spawning alternate network checks
DEBUG actor_alternate_network::checks: Starting alternate network check name="wfl-office-macos"
DEBUG actor_alternate_network::checks: Starting alternate network check name="wfl-office-linux"
DEBUG actor_alternate_network::checks: fingerprint matched name=wfl-office-macos network_host=172.31.0.203:4443
DEBUG actor_alternate_network::checks: Detected alternate network name=wfl-office-macos
# Windows 서비스 로그 — 같은 판정
INFO actor_configuration::handler: Detected alternate network name=Some("wfl-office-windows")
비콘은 응답하지만(subject=CN=wfl-beacon) 관리 네트워크를 등록하기 전이라 warp-cli debug alternate-network 가 <None> 이고, Profile ID 는 기본 시험 프로필입니다.
단말에 emergency_disconnect.json 파일 하나를 두면, Cloudflare 에 닿지 않아도 클라이언트가 끊기나요?
MDM 에 local_emergency_signal_enabled 를 켜 둔 단말은 파일에 true 를 쓰자 세 OS 모두 1초 안에 끊겼고, 파일을 지우자 4초 안에 다시 붙었습니다. 다만 Windows 에서 PowerShell 5.1 의 Out-File 로 쓴 파일은 UTF-16 이라 오류 없이 무시됐습니다.
Windows
조건부
파일을 쓰자 0.3초 만에 Admin directed disconnect, 지우자 3.5초 뒤 Connected. 단 Out-File(UTF-16)로 쓴 파일은 오류 없이 무시됨
macOS
된다
파일을 쓰자 1초 안에 끊기고, 지우자 1~3초 뒤 Connected. 창 표시만 늦어 27초 안에 바뀜
Linux
된다
파일을 쓰자마자 Admin directed disconnect, 지우자 4초 뒤 Connected
파일만 두어서는 아무 일도 일어나지 않습니다. MDM 에 local_emergency_signal_enabled 를 미리 켜 둔 단말에서만 동작했습니다.
Windows 에서는 문서 예시처럼 Set-Content 를 쓰거나 인코딩을 ASCII 또는 UTF-8(BOM 없음)로 지정합니다. PowerShell 5.1 의 Out-File 과, 같은 동작을 하는 > 리디렉션은 쓰지 않습니다(실습에서는 Set-Content -Encoding ASCII 로 동작을, Out-File 로 무시되는 것을 확인).
끊기지 않으면 서비스 로그에서 emergency disconnect file 줄을 찾습니다. 인코딩 오류와 JSON 오류가 그대로 찍혔습니다.
로그에는 로컬 파일 신호가 대시보드의 Global disconnect 값(꺼짐)보다 우선한다고 찍혔고, 파일을 지우면 곧바로 복귀했습니다. 끊겼는지 빨리 보려면 창보다 warp-cli status 를 봅니다.
Cloudflare 에 닿지 않는 상황에서도 관리자가 단말의 클라이언트를 끊을 수 있게 하는 「로컬 비상 해제」입니다. MDM(관리 도구로 나눠 주는 클라이언트 설정 파일)에 local_emergency_signal_enabled 를 켜 두면, 클라이언트가 정해진 위치의 emergency_disconnect.json 을 읽고 내용이 {"emergency_disconnect": true} 이면 스스로 끊습니다. 문서에는 2026.5.0 이상에서 쓸 수 있고 30초 안에 반응한다고 적혀 있습니다.
세 OS 실습 PC(클라이언트 2026.7)에서 MDM 에 키를 넣기 전과 넣은 뒤에 각각 파일을 썼습니다. 파일 위치는 Windows %PROGRAMDATA%\Cloudflare\, macOS /Library/Application Support/Cloudflare/, Linux /var/lib/cloudflare-warp/ 입니다.
올바른 JSON 과 일부러 깨뜨린 JSON({"emergency_disconnect": tru)을 써 보고, 1~4초 간격으로 warp-cli status 와 서비스 로그를 기록하며 창을 캡처했습니다.
Windows 에서는 같은 내용을 PowerShell 5.1 의 기본 Out-File 로도 써 봤습니다. 이 PC 는 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
마지막으로 파일을 지우고 다시 붙기까지 걸린 시간을 쟀습니다. 원격으로 되돌리지 못할 때를 대비해, 일정 시간 뒤 파일 삭제와 MDM 복원이 저절로 도는 예약 작업을 먼저 걸어 두었습니다.
키를 켠 단말은 올바른 파일을 쓰자 1초 안에 Disconnected · Admin directed disconnect 가 됐고, 파일을 지우자 1~4초 안에 Connected 로 돌아왔습니다. 문서의 「30초 안」보다 훨씬 빨랐고, 끊긴 동안 창에는 연결·일시정지 버튼이 없었습니다.
MDM 키가 없을 때와 JSON 이 깨졌을 때는 40~45초 동안 지켜보는 내내 Connected 그대로였습니다. 깨진 JSON 은 로그에 invalid JSON, ignoring 한 줄만 남았습니다.
Windows 에서 Out-File 로 쓴 파일은 UTF-16 이라 stream did not contain valid UTF-8 로 무시되어 끊기지 않았습니다. Set-Content(-Encoding ASCII)로 쓴 파일은 바로 동작했습니다.
macOS 는 상태가 1초 안에 끊겼는데도 창은 9초 뒤에도 Connected 였고, 27초 안에 바뀌었습니다. 메뉴 막대 아이콘이 먼저 바뀌었습니다.
# Windows 서비스 로그 — 올바른 파일을 쓴 순간
Local emergency disconnect file indicates DISCONNECT path=C:\ProgramData\Cloudflare\emergency_disconnect.json
Local file emergency disconnect state changed: None -> true
Using active local file emergency disconnect state (supersedes external: None, API: Some(false))
# 같은 내용을 PowerShell 5.1 Out-File(UTF-16)로 쓰면 — 끊기지 않음
Failed to read local emergency disconnect file err=Error { kind: InvalidData, message: "stream did not contain valid UTF-8" }
Retaining current local file disconnect state due to inconclusive check disconnect=false
# 파일을 지우자 3.5초 뒤 Connected
Local emergency disconnect file not found, treating as normal operation
MDM 에 local_emergency_signal_enabled 가 들어 있는 것을 확인하고 파일에 true 를 쓰자, 첫 조회부터 Disconnected · Admin directed disconnect 였습니다. 파일을 지우자 4초 뒤 Connected 로 돌아왔습니다(화면 시각은 UTC).
프로필에서 서비스 모드를 바꾸면 단말에서는 무엇이 달라지나요?
다섯 모드 모두 프로필에서 바꾼 대로 적용됐고, 창 문구(Your Internet is protected. 등)만 봐도 지금 모드를 알 수 있었습니다. 다만 MDM 파일에 service_mode 가 있으면 프로필 모드는 무시됐고, posture_only 는 이 실습 조직에서 연결에 실패했습니다.
Windows
조건부
5종 모두 서비스 재시작 없이 0~49초 만에 적용되고 모드마다 창 문구가 다름. 단 posture_only 는 Device identity not found 로 붙지 않음
macOS
조건부
5종 모두 적용. 트래픽만 보내는 모드(warp_tunnel_only)에서는 이름 풀이를 PC 원래 DNS 서버가 맡음. 단 posture_only 는 붙지 않음
Linux
조건부
DNS 만 보내는 모드(1dot1)에서는 터널이 없어지고 웹 요청이 터널을 타지 않는 등 5종 모두 적용. 단 posture_only 는 붙지 않음
MDM 파일에 service_mode 가 있으면 프로필에서 바꾼 모드는 무시됩니다. warp-cli settings 의 Mode 줄 앞이 (local policy) 면 MDM 값, (network policy) 면 프로필 값입니다. 대시보드로 모드를 관리하려면 MDM 에서 이 키를 뺍니다(세 OS 실습 PC 에서 확인).
warp_tunnel_only·proxy 는 DNS 를 Cloudflare 로 보내지 않으므로(로그 dns_mode=No DNS) Gateway DNS 정책이 걸리지 않습니다. 설정과 경로까지만 확인했고 DNS 정책 로그는 보지 않았습니다.
posture_only 는 이 실습 조직에서 서비스 토큰 등록이든 사람 로그인 등록(Linux·Windows)이든 세 OS 모두 Failed to set mTLS Identity(창에는 Device identity not found)로 붙지 않았습니다(「Posture only 연결 실패」 항목). 쓰기 전에 한 대에서 먼저 확인합니다.
Linux 의 1dot1 은 /etc/resolv.conf 를 직접 바꿔서 resolvectl 에는 VPC 리졸버만 보입니다. DNS 경로는 resolv.conf 로 확인합니다.
클라이언트의 서비스 모드는 단말의 무엇을 Cloudflare 로 보낼지 정합니다. 기본인 warp(Traffic and DNS), DNS 만 보내는 1dot1(DNS only), 트래픽만 터널로 보내는 warp_tunnel_only(Traffic only), 로컬 프록시 포트로 들어온 요청만 보내는 proxy(Local proxy), 트래픽은 보내지 않고 단말 상태 확인에만 쓰는 posture_only(Posture only) 다섯 가지입니다. 대시보드의 단말 프로필이나 MDM 파일(관리 도구로 나눠 주는 클라이언트 설정 파일)의 service_mode 로 정합니다.
세 OS 실습 PC 의 단말 프로필 모드를 warp → 1dot1 → warp_tunnel_only → proxy(포트 40000) → posture_only 순서로 바꾸고 마지막에 되돌렸습니다. Windows 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
모드마다 warp-cli settings 의 Mode 줄, 창 문구, /cdn-cgi/trace 의 warp=·gateway= 값(요청이 터널과 Gateway 를 거쳤는지 보여 주는 값), DNS 서버, 1.1.1.1 로 가는 경로, IPv6 요청, ping 을 적었습니다.
실습 PC 의 MDM 파일에는 service_mode=warp 가 있어 시험하는 동안만 그 키를 뺐습니다. Windows 는 바꾼 뒤 재시작 없이 기다렸고, macOS·Linux 는 기다리지 않으려고 바꿀 때마다 서비스를 재시작했습니다.
PMTUD(터널에 맞는 패킷 크기를 스스로 찾는 기능)는 MDM 의 enable_pmtud 를 넣고 빼며 터널 MTU 를 봤습니다.
모드마다 창 문구가 달랐습니다. warp 는 Your Internet is protected., 1dot1 은 Your DNS queries are protected., warp_tunnel_only 는 HTTP and network traffic is protected., proxy 는 Proxied traffic is protected. 입니다. Windows 에서는 바꾸고 0~49초 안에 반영됐습니다.
1dot1 에서는 터널 인터페이스(CloudflareWARP)가 없어지고 웹 요청이 warp=off 였습니다. DNS 는 클라이언트를 거쳐 Gateway 로 갔습니다(Linux 에서 그때 푼 이름이 Gateway DNS 로그에 남음). warp_tunnel_only 는 웹 요청이 터널을 탔지만 DNS 는 단말의 원래 리졸버(VPC)가 풀었습니다(macOS·Linux). proxy 는 127.0.0.1:40000 SOCKS 로 보낸 요청만 warp=on 이었습니다.
warp·warp_tunnel_only 에서는 터널 인터페이스에 가상 IPv4(100.96.x.x)와 가상 IPv6(2606:4700:cf1:1000::/64 대역)가 붙어, 바깥 IPv6 가 없는 PC 에서도 IPv6 요청이 터널로 나갔습니다(macOS 는 IPv6 주소를 직접 쓸 때). 조직의 ICMP 프록시가 꺼져 있는데도 ping 1.1.1.1 은 모든 모드에서 응답했습니다(누가 답했는지는 확인하지 않았습니다).
PMTUD 는 Windows·macOS 에서 MDM 키가 없어도 켜져 있어 터널 MTU 가 1300 이었고, macOS 는 enable_pmtud=false 로 끄자 1280 이 됐습니다. Linux 는 키가 없을 때 1280, enable_pmtud=true 를 넣자 1300 이 됐습니다.
# 모드별 서비스 로그의 Starting Warp Connection 줄 (Windows)
operation_mode=WarpWithDnsOverHttps dns_mode=DNS Proxy tunnel_mode=Exclude-only Tunnel
operation_mode=DnsOverHttps dns_mode=DNS Proxy tunnel_mode=No Tunnel
operation_mode=TunnelOnly dns_mode=No DNS tunnel_mode=Exclude-only Tunnel
operation_mode=WarpProxy on port 40000 dns_mode=No DNS tunnel_mode=No Tunnel … forward_proxy=enabled
operation_mode=PostureOnly dns_mode=No DNS tunnel_mode=No Tunnel posture_only=enabled
# posture_only 상태와 로그
Reason: Failed to set mTLS Identity
code: 7453, message: Some("Invalid CSR")
# macOS, enable_pmtud 키 없음
warp_edge::tunnel_loop: New PMTU found, adjusting tunnel mtu pmtu=1300
기본 모드(warp, Traffic and DNS)에서는 창에 Connected · Your Internet is protected. 가 보입니다. 웹 요청은 터널과 Gateway 를 거쳤습니다(warp=on · gateway=on).
프로필의 관리자 제어(스위치 잠금·자동 연결·모드 전환 등)는 단말에서 무엇을 바꾸나요?
항목마다 프로필 값이 단말에 내려왔고, 스위치 잠금을 켜면 창의 Pause 버튼이 사라지고 warp-cli disconnect 도 거부됐습니다. 다만 MDM 파일에 같은 키가 있으면 MDM 값이 이겼습니다. 자동 연결은 프로필의 2분이 아니라 MDM 의 1분대로 다시 붙었고, 조직 탈퇴는 계속 막혀 있었습니다.
Windows
조건부
잠금은 적용 8초 뒤 반영돼 disconnect 거부·Pause 사라짐. wfl.test 가 DNS 검색 목록 맨 앞에 붙음. 단 자동 연결은 MDM 값(1분)대로 61초에 다시 붙음(프로필은 120초)
macOS
조건부
잠금으로 disconnect 거부·Pause 사라짐. 단 자동 연결은 MDM 키가 있으면 62초(MDM 값), 빼야 122초(프로필 값). 프로필 변경이 2분 넘게 안 내려와 다시 연결해서 받음
Linux
조건부
적용 65초 뒤 잠금, disconnect 거부. 단 자동 연결은 MDM 키가 있으면 61초, 빼야 121초. resolv.conf 검색 목록에 wfl.test
warp-cli settings 줄 앞에 (local policy) 가 붙으면 MDM 값, (network policy) 가 붙으면 프로필 값입니다. MDM 으로 배포한 단말의 자동 연결을 대시보드에서 정하려면 MDM 파일에서 auto_connect 를 뺍니다(macOS·Linux 실습 PC 에서 확인).
자동 연결은 프로필에 초 단위(120)로 넣었지만 단말에는 분 단위(2)로 표시됐습니다.
스위치 잠금을 켜면 사용자는 명령줄로도 연결을 끊지 못합니다. 장애 때 급히 풀 방법(관리자 우회 코드 등)을 미리 정해 둡니다. 이 항목에서는 우회 코드를 시험하지 않았습니다.
macOS 에서는 프로필을 바꾸고 2분이 지나도 새 값이 내려오지 않아, 다시 연결하거나 서비스를 재시작해서 받았습니다(두 번). Windows 는 8~37초, Linux 는 65초 만에 반영됐습니다.
단말 프로필(대시보드의 Device profile)에는 사용자가 클라이언트를 얼마나 다룰 수 있는지 정하는 관리자 제어 항목이 있습니다. 사용자가 끄지 못하게 하는 Lock device client switch(스위치 잠금), 끊긴 뒤 정한 시간이 지나면 다시 붙는 Auto connect(자동 연결), 사용자에게 모드 선택을 맡기는 Mode switch, Allow device to leave organization(조직 탈퇴 허용), 문의 버튼이 여는 Support URL, 공용 와이파이 로그인 화면을 기다려 주는 Captive portal detection, 짧은 이름 뒤에 붙일 도메인을 정하는 DNS search suffixes, Windows 전용 인터페이스 옵션(SCCM VPN 경계, 터널 IP 의 DNS 등록)입니다.
세 OS 실습 PC 의 단말 프로필에 스위치 잠금, 자동 연결 120초, 모드 전환 허용, 조직 탈퇴 허용 끔·켬, 지원 URL, 캡티브 포털 60초, DNS 접미사 wfl.test 를 넣었습니다. Windows 에는 SCCM VPN 경계 켬과 터널 IP DNS 등록 끔도 넣었습니다. Windows 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
바꾸기 전과 뒤에 warp-cli settings 의 해당 줄, 창의 Home 화면, warp-cli disconnect 결과, 단말의 DNS 검색 목록을 비교했습니다.
실습 PC 의 MDM 파일(관리 도구로 나눠 주는 클라이언트 설정 파일)에는 auto_connect=1(1분)이 들어 있습니다. macOS·Linux 에서는 이 키만 뺀 파일로 한 번 더 끊고, 다시 붙기까지 걸린 시간을 쟀습니다.
스위치 잠금을 켜자 창에서 Pause 버튼이 사라졌고, warp-cli disconnect 는 Error: Operation not authorized in this context. 로 거부됐습니다(세 OS).
모드 전환을 허용하자 Home 에 Mode 선택 상자(DNS only / Traffic and DNS)가 생겼습니다. macOS·Linux 에서는 사용자가 고른 모드가 MDM 의 service_mode 보다 우선했습니다. 지원 URL 을 넣으면 창 왼쪽 아래에 말풍선 버튼이 생겼고, 누르면 그 주소가 브라우저로 열렸습니다.
DNS 접미사 wfl.test 는 단말의 DNS 검색 목록에 들어갔습니다(Windows 는 전역 목록 맨 앞). Windows 에서 터널 IP DNS 등록을 끄자 WARP 어댑터의 RegisterThisConnectionsAddress 가 False 가 됐습니다. SCCM VPN 경계는 SCCM 클라이언트가 없는 PC 라 어댑터 설명이 바뀌지 않았고, 이는 문서에 적힌 조건과 같습니다. 캡티브 포털은 설정 줄의 값만 확인했습니다.
MDM 파일에 같은 키가 있으면 MDM 값이 이겼습니다. 자동 연결은 프로필의 120초가 아니라 MDM 의 1분대로 61~62초 만에 다시 붙었고, 키를 빼자 121~122초 걸렸습니다. MDM 으로 배포한 단말은 조직 탈퇴 허용을 켜도 Allowed to Leave Org: false 였습니다(macOS·Linux 에서 켜고 끄며 확인).
# 스위치 잠금 적용 뒤 warp-cli settings 일부 (macOS)
(override) Switch Locked: true
(local policy) Auto Connect: 1
(network policy) Support URL: https://waf-lab.mzc.cflare.kr/lab/
(network policy) Allow Mode Switch: true
(local policy) Allowed to Leave Org: false
(network policy) DNS Search Suffixes: wfl.test
$ warp-cli disconnect
Error: Operation not authorized in this context.
# MDM 에서 auto_connect 를 뺀 뒤 (프로필 120초)
(network policy) Auto Connect: 2
기본 상태의 Home 화면입니다. 사용자가 누를 수 있는 Pause 버튼이 있고, 모드 선택 상자와 지원 버튼은 없습니다.
클라이언트 이름이 Cloudflare One Client 로 바뀌었습니다. 패키지·서비스·실행 파일도 새 이름으로 찾으면 되나요?
아니요. 새 이름은 창 제목·설치 목록 같은 눈에 보이는 자리에만 들어왔고(macOS 는 앱 이름도 아직 Cloudflare WARP), 패키지·서비스·실행 파일·폴더는 세 OS 모두 warp·1dot1dot1dot1 같은 옛 이름 그대로였습니다. MDM 의 service_mode 에 없는 값을 넣으면 오류 없이 그 값만 무시됐습니다.
Windows
안 된다
새 이름은 창·설치 목록·서비스 표시 이름에만. 폴더·서비스 이름·실행 파일은 Cloudflare WARP·CloudflareWARP·warp-svc.exe 그대로. 없는 모드 값은 무시되고 5.7초 뒤 연결
macOS
안 된다
앱부터 Cloudflare WARP.app, 번들 id com.cloudflare.1dot1dot1dot1.macos. 새 이름은 등록·로그인 화면에만. 없는 모드 값은 무시되고 1초 뒤 연결
Linux
안 된다
메뉴 항목·창 이름만 Cloudflare One Client, 패키지 cloudflare-warp·서비스 warp-svc 그대로. 없는 모드 값은 무시되고 1초 뒤 연결
방화벽 프로그램 규칙·EDR 예외·스크립트는 새 이름으로 고치지 않고 실제 경로와 이름으로 적습니다. 예를 들어 C:\Program Files\Cloudflare\Cloudflare WARP\warp-svc.exe, 서비스 CloudflareWARP, 패키지 cloudflare-warp, 앱 Cloudflare WARP.app 입니다(2026.7 에서도 그대로인 것을 확인).
설치 목록 이름으로 찾는 자산 관리 규칙에는 Cloudflare WARP 와 Cloudflare One Client 를 둘 다 넣습니다. Windows 에서 버전에 따라 이름이 달랐습니다.
MDM 의 service_mode 에는 문서에 있는 값(warp·1dot1·proxy·postureonly·tunnelonly)만 씁니다. 틀린 값은 오류 없이 무시되므로, 배포한 뒤 warp-cli settings 의 Mode 줄 앞이 (local policy)(MDM 값이 적용됨)인지 확인합니다.
Linux 에서 버전은 warp-cli --version 으로 봅니다. warp-svc --version 은 버전을 찍지 않고 서비스 본체를 하나 더 띄우려 했습니다(곧 스스로 끝났고 원래 서비스는 영향이 없었습니다).
데스크톱 클라이언트의 이름이 Cloudflare WARP 에서 Cloudflare One Client 로 바뀌고 있습니다(공식 문서도 「Cloudflare One Client (formerly WARP)」로 적습니다). 자산 관리 규칙, EDR(단말 보안 제품) 예외, 방화벽의 프로그램 규칙, 배포 스크립트는 이름으로 클라이언트를 찾습니다. 그래서 새 이름이 어디까지 들어왔는지 OS 마다 확인했습니다.
세 OS 실습 PC(클라이언트 2026.7)에서 설치 목록·패키지 정보, 서비스, 실행 파일, 시작 메뉴·바탕화면 항목, 설정 폴더, 명령 버전, 진단 zip 이름을 적었습니다. Windows 는 클라이언트 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
Windows 는 같은 PC 에 옛 버전(2025.2.600.0)을 깔았다가 2026.7.1376.0 을 덮어 깔고 이름이 어떻게 바뀌는지 봤습니다.
MDM 파일(관리 도구로 나눠 주는 클라이언트 설정 파일)에서 service_mode 만 없는 값 cloudflare_one 으로 바꾸고 서비스를 다시 시작한 뒤, warp-cli settings 의 Mode 줄과 서비스 로그를 봤습니다. 끝나면 원래 파일로 되돌렸습니다.
모바일 앱은 실습 PC 세트에 없어 보지 않았습니다.
Windows: 설치 목록·창 제목·시작 메뉴 바로가기·서비스 표시 이름은 Cloudflare One Client 였습니다. 설치 폴더는 C:\Program Files\Cloudflare\Cloudflare WARP, 서비스 이름은 CloudflareWARP, 실행 파일은 Cloudflare WARP.exe·warp-svc.exe·warp-cli.exe, 네트워크 어댑터는 CloudflareWARP 였습니다. 옛 버전은 설치 목록 이름이 Cloudflare WARP 였고, 덮어 깔자 이름만 바뀌었습니다.
macOS: 같은 2026.7.1376.0 인데도 앱 이름부터 Cloudflare WARP.app 이었습니다. 설치 패키지·번들 id 는 옛 소비자 앱 이름이 남은 com.cloudflare.1dot1dot1dot1.macos, 데몬은 com.cloudflare.1dot1dot1dot1.macos.warp.daemon 이고, 새 이름은 등록·로그인 화면에서만 보였습니다.
Linux: 메뉴 항목·창 이름은 Cloudflare One Client 인데, 패키지는 cloudflare-warp, 서비스는 warp-svc(설명 Cloudflare Zero Trust Client Daemon), 설정 폴더는 /var/lib/cloudflare-warp 였습니다. 명령 warp-cli·warp-diag 와 진단 파일 warp-debugging-info-*.zip 은 세 OS 가 같았습니다.
MDM 의 service_mode 에 없는 값을 넣어도 파일 전체가 거부되지는 않았습니다. 그 키만 무시되고 프로필 모드((network policy))로 1~6초 안에 연결됐으며, 서비스 로그에 Invalid value for service_mode: unknown service mode 경고만 남았습니다. 나머지 MDM 키(조직 이름 등)는 그대로 읽혔습니다(macOS·Linux 에서 확인).
# Linux: 패키지와 메뉴 항목 이름
ii cloudflare-warp 2026.7.1377.0 amd64 Cloudflare Warp Client
Name=Cloudflare One Client
# macOS: 앱 정보
CFBundleName = Cloudflare WARP, CFBundleIdentifier = com.cloudflare.1dot1dot1dot1.macos
# Windows: 서비스 이름과 실행 파일 (표시 이름은 Cloudflare One Client)
CloudflareWARP → "C:\Program Files\Cloudflare\Cloudflare WARP\warp-svc.exe"
# MDM service_mode=cloudflare_one 을 넣은 뒤 (세 OS 같음)
WARN warp_settings::raw_settings::mdm: Invalid value for service_mode: unknown service mode
(network policy) Mode: WarpWithDnsOverHttps
옛 버전(2025.2.600.0)을 깔았을 때의 창입니다. 아래쪽에 WARP by Cloudflare 가 보이고, 이 버전은 설치 목록 이름도 Cloudflare WARP 였습니다.
WARP 를 켜면 Cloudflare Tunnel 뒤의 사설 IP 앱에 바로 닿나요?
기본 Split Tunnel(Exclude 모드)이 사설 대역을 터널 밖으로 보내서, WARP 를 켜기만 해서는 세 OS 모두 닿지 않았습니다. 앱 주소 한 줄을 Include 에 넣자 0.3초 안에 Access 로그인 화면으로 넘어갔습니다.
Windows
조건부
기본 Exclude 에서는 Ethernet 으로 나가 10초 동안 응답 없음. 172.31.0.19/32 를 Include 에 넣자 12초 뒤 저절로 반영됐고, CloudflareWARP 경로로 0.3초에 302 → Access 로그인
macOS
조건부
기본 Exclude 에서는 en4 로 나가 10초 시간 초과. Include 뒤 utun4(WARP) 경로로 0.25초에 302 → Access 로그인. 단, 프로필 변경이 2분 넘게 내려오지 않아 다시 연결하고 12초 뒤에 반영됨
Linux
조건부
기본 Exclude 에서는 ens5 로 나가 연결 실패(rc=7). Include 뒤 CloudflareWARP 경로로 0.28초에 302 → Access 로그인
사설 앱 주소를 Split Tunnel 로 터널에 넣습니다(Include 에 추가, 세 OS 에서 확인). Exclude 모드를 유지한 채 기본 목록에서 그 대역만 빼는 방법은 이번에 해 보지 않았습니다.
Include 모드에서는 목록에 없는 목적지가 터널을 타지 않습니다. 목록 밖 주소가 warp=off 로 나온 것은 「M365 먼 서버 접속」 항목에서 확인했습니다.
프로필을 바꿨는데 단말이 그대로면 한 번 끊었다 다시 연결합니다(warp-cli disconnect → connect, 또는 창의 스위치 끄고 켜기). macOS 에서는 넣을 때와 뺄 때 모두 2분 넘게 반영되지 않다가, 다시 연결하고 12초 뒤에 반영됐습니다.
Pulumi 로 Split Tunnel 항목을 도메인(host)에서 주소(address)로 바꾸자 API 가 400(cannot update split tunnels: host and Address both cannot be present)으로 거절했습니다. 설정 도구에 저장된 상태를 대시보드의 실제 값에 맞춘 뒤 다시 적용하자 됐습니다(Windows · Linux).
WARP-to-Tunnel 은 단말의 WARP(Cloudflare One Client)로 들어간 트래픽을 Cloudflare Tunnel(사설망 안 서버가 Cloudflare 쪽으로 바깥 방향 연결만 여는 커넥터, cloudflared)로 넘겨, VPN 없이 회사 사설 IP 에 닿게 하는 경로입니다. 어느 목적지를 터널에 태울지는 단말 프로필의 Split Tunnel(터널에 태우거나 뺄 목적지 목록)이 정합니다. 실습에서는 사설 IP 앱 하나(172.31.0.19:80) 앞에 로그인을 요구하는 Access 사설 앱 정책을 걸어 두었습니다.
세 OS 실습 PC 는 앱과 같은 클라우드 사설망(VPC)에 있지만, 보안 그룹(클라우드 방화벽)이 PC 에서 앱으로 바로 들어오는 연결을 막아 둡니다. 그래서 터널을 거쳐야만 닿습니다.
기본 프로필(Exclude 모드, 16줄) 그대로 172.31.0.19 로 가는 경로(Find-NetRoute · route -n get · ip route get)와 curl http://172.31.0.19/ 결과를 봤습니다.
Split Tunnel 을 Include 모드로 바꿔 172.31.0.19/32 한 줄만 넣은 뒤 같은 확인을 하고, 브라우저로 같은 주소를 열었습니다. 끝난 뒤 원래 Exclude 목록으로 되돌렸습니다.
세 PC 모두 서비스 토큰(사람 대신 쓰는 기계용 인증 값)으로 등록해 사람 신원이 없는 단말입니다. 로그인 화면까지만 보고 로그인은 하지 않았습니다.
기본 Exclude 목록에 사설 대역 10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16 이 들어 있습니다. 그래서 WARP 를 켜 둬도 사설 IP 로 가는 연결은 물리 랜카드로 나갔고, 10초 동안 응답이 없거나(Windows · macOS) 바로 실패했습니다(Linux).
Include 에 넣자 경로가 WARP 인터페이스(CloudflareWARP · utun4)로 바뀌었습니다. 0.25~0.3초 만에 HTTP/1.1 302 Found(다른 주소로 보내는 응답)와 함께 Access 로그인 주소 /cdn-cgi/access/login/172.31.0.19 가 돌아왔고, 브라우저에는 Log in to 실습 D … 화면이 떴습니다.
서비스 토큰으로 등록한 단말이라도 사설 앱의 사람 로그인을 대신하지는 않았습니다. Access 가 준 토큰에는 "is_warp":true · "private_app_flow":1 이 들어 있었습니다(Windows · Linux 에서 확인).
macOS 에서는 넣은 한 줄 말고도 클라이언트가 Cloudflare One 대역 2줄(172.64.128.0/20 · 2606:4700:cf1::/48)을 Include 목록에 스스로 붙였습니다. 공식 문서에 적힌 자동 관리 대역과 같습니다.
# macOS (KST) — 기본 Exclude / Include 1줄(다시 연결한 뒤)
13:38:24 interface: en4 http=000 time=10.006990s rc=28
13:44:49 interface: utun4 http=302 time=0.251102s
(network policy) Include mode, with hosts/ips:
172.31.0.19/32
172.64.128.0/20 (Cloudflare One Token CIDR)
2606:4700:cf1::/48 (Cloudflare One CIDR)
< Location: https://mzccfteam.cloudflareaccess.com/cdn-cgi/access/login/172.31.0.19?kid=…&meta=…&redirect_url=%2F
# Windows · Linux — 앞선 도메인 Include 흔적이 남은 채 첫 적용
PATCH …/devices/policy/<uuid>: 400 Bad Request {… "errors":[{"code":2049,"message":"cannot update split tunnels: host and Address both cannot be present"}] …}
기본 Exclude 모드 목록에 사설 대역이 들어 있어, WARP 가 켜져 있는데도 172.31.0.19 로 가는 길이 물리 랜카드(ens5)였습니다. curl 은 연결하지 못했습니다(rc=7).
클라이언트에서 VNET(가상 네트워크)을 바꾸면 같은 사설 IP 로 가는 길도 바뀌나요?
바뀝니다. 경로가 없는 VNET 을 고르자 세 OS 모두 같은 사설 IP 앱에 닿지 못했고, 상태는 계속 Connected 였습니다. default 로 되돌리자 곧바로 Access 로그인(302)이 돌아왔습니다.
Windows
된다
wfl-vnet-b 로 바꾸자 8초 시간 초과(rc=28). 창의 VNET 칸에서 default 를 고르자 0.26초에 302
macOS
된다
wfl-vnet-b 로 바꾸자 4초 뒤 연결 실패(rc=7). 창의 VNET 칸에서 default 를 고르자 0.22초에 302
Linux
된다
이름으로는 Invalid Virtual Network ID provided. 로 거부됐고 ID 로만 바뀜. wfl-vnet-b 에서 0.006초 만에 연결 거부, default 로 돌리자 0.20초에 302
사내 앱만 안 된다는 문의가 오면 클라이언트에서 어느 VNET 이 골라져 있는지부터 봅니다(창 Home 의 VNET 칸, warp-cli vnet 의 Currently selected). 세 OS 모두 default 로 돌리자 바로 돌아왔습니다.
Linux CLI 로 바꿀 때는 VNET 이름 대신 ID 를 넣습니다(warp-cli vnet 목록의 ID). 실습에서 확인했습니다.
사용자가 잘못된 VNET 을 고르지 않게 하려면 단말 프로필의 VNET availability(베타, 프로필마다 보이는 VNET 을 줄이는 설정)를 씁니다. 이번에 해 보지는 않았습니다.
공식 문서상 이 설정이 없으면 계정의 모든 VNET 이 선택 칸에 보입니다. VNET 을 하나 새로 만들기만 해도 모든 사용자 창에 선택 칸이 생길 수 있습니다. 시험 PC 밖의 단말에서는 확인하지 않았습니다.
가상 네트워크(VNET)는 같은 사설 IP 대역이 여러 환경(예: 운영과 검증)에 겹칠 때 쓰는 기능입니다. Cloudflare 쪽 라우팅 표(어느 주소를 어느 터널로 보낼지 적은 표)를 VNET 마다 따로 둡니다. 공식 문서에 따르면 한 VNET 에 넣은 경로는 다른 VNET 의 라우팅 표에 나타나지 않습니다. 사용자는 클라이언트 창이나 CLI(warp-cli vnet)에서 VNET 을 고릅니다.
실습 사설 앱 172.31.0.19(앞에 Access 로그인이 걸려 있습니다)로 가는 경로는 원래 있던 default VNET 에만 있습니다. 여기에 경로가 하나도 없는 VNET wfl-vnet-b 를 하나 더 만들었습니다.
기본 Exclude 목록이 사설 대역을 터널 밖으로 보내므로, 세 OS 프로필의 Include 에 앱 주소 한 줄을 넣었습니다(「사설 IP 기본 제외」 항목).
세 OS 실습 PC 에서 warp-cli vnet 으로 목록을 보고 wfl-vnet-b 로 바꾼 뒤 curl http://172.31.0.19/ 를 두 번 실행했습니다.
Windows·macOS 는 창의 Home 에 있는 VNET 칸에서, Linux 는 CLI 로 default 로 되돌린 뒤 한 번 더 확인했습니다.
wfl-vnet-b 에서는 세 OS 모두 사설 앱에 닿지 못했습니다. 실패 모양은 OS 마다 달랐습니다. Linux 는 0.006초 만에 연결 거부, macOS 는 4초 뒤 연결 실패, Windows 는 8초 시간 초과였습니다.
그동안 상태는 계속 Connected 였고, 단말 안의 경로(사설 IP → WARP 인터페이스)도 그대로였습니다. 바뀐 것은 Cloudflare 쪽 라우팅 표뿐이어서, 사용자에게는 「연결은 됐는데 사내 앱만 안 된다」로 보입니다.
default 로 되돌리자 세 OS 모두 0.20~0.26초에 302(Access 로그인 주소로 보내는 응답)가 돌아왔습니다.
VNET 이 두 개가 되자 Windows·macOS 창의 Home 에 VNET 선택 칸이 새로 생겼습니다. Linux CLI 는 이름(warp-cli vnet wfl-vnet-b)을 거부했고, ID 를 넣어야 바뀌었습니다.
# Windows (KST) — default → wfl-vnet-b → 창에서 default
Currently selected: <uuid> # default
curl http://172.31.0.19/ -> http=302 t=0.239070 rc=0 at 18:28:25 KST
Currently selected: <uuid> # wfl-vnet-b
Status update: Connected
curl http://172.31.0.19/ -> http=000 t=8.000494 rc=28 at 18:28:39 KST
Currently selected: <uuid> # default
curl http://172.31.0.19/ -> http=302 t=0.255223 rc=0 at 18:32:24 KST
# Linux — 이름으로 바꾸려 할 때
Error: Invalid Virtual Network ID provided.
VNET 을 하나 더 만들자 Home 에 VNET 선택 칸이 생겼습니다. 펼치면 default 와 wfl-vnet-b 두 개가 보이고, 지금 고른 쪽에 체크 표시가 붙습니다.
등록을 지운 사용자를 등록 정책에서 빼면 다시 로그인해도 재등록이 막히나요?
막혔습니다. 뺀 동안에는 다시 등록되지 않았고, 정책을 되돌리자 곧바로 다시 등록됐습니다. 다만 사용자에게 「거부」 문구는 보이지 않았습니다. 브라우저는 리디렉션 반복에 걸리거나 코드 메일이 오지 않았고, 관리자 로그에도 남지 않았습니다.
Linux
된다
등록 삭제 57초 뒤 Registration Missing 이 된 뒤 다시 등록되지 않음. 거부 문구 없이 리디렉션 반복과 코드 메일 미발송(대조 주소는 6초 만에 받음)으로만 보임. 정책을 되돌리자 코드 없이 다시 등록됨
퇴사자나 분실 단말처럼 등록을 영구히 끝낼 때는 등록을 지우는 것과 함께 등록 정책에서도 사람을 뺍니다. 뺀 동안 재등록이 막히고, 되돌리면 곧바로 다시 등록되는 것을 Linux 실습 PC 에서 확인했습니다. 이번에 시험한 방법은 Exclude 에 메일 주소를 넣는 것뿐입니다.
「로그인 창이 리디렉션을 반복한다」거나 「코드를 보냈다는데 메일이 안 온다」는 문의가 오면, 그 사람이 등록 정책 Exclude 에 들어 있지 않은지 먼저 봅니다. 이 실습 조직에서는 막힌 시도가 로그에 남지 않아, 로그만으로는 찾을 수 없었습니다.
같은 주소로 짧은 간격을 두고 코드를 거듭 요청하면, 정책과 상관없이 메일이 오지 않을 때가 있었습니다(원인 미확인). 「코드가 안 온다」 문의는 이 경우와 구분해야 합니다.
Windows·macOS 에서는 시험하지 않았습니다.
단말 등록 정책(Device enrollment permissions)은 누가 클라이언트를 조직에 등록할 수 있는지 정하는 Access 정책입니다. 공식 문서에는 「사용자가 다시 인증할 수 있으면 등록을 지우거나 revoke 해도 영구적이지 않다」는 문장이 있습니다. 등록 정책에서 사람을 빼면 정말 막히는지, 막힐 때 사용자와 관리자에게 무엇이 보이는지 확인했습니다. 지운 등록이 저절로 되살아나는 동작은 「같은 PC 여러 줄」 항목에 있습니다.
Linux 실습 PC(클라이언트 2026.7)는 One-time PIN(메일로 받는 일회용 코드)으로 로그인해 신원 등록해 두었습니다.
등록 정책의 Exclude(제외 조건)에 이 사용자의 메일 주소를 넣었습니다. 그다음 API 로 단말 등록을 지우고 5초마다 상태를 기록했습니다.
다시 로그인은 두 곳에서 해 봤습니다. 하나는 클라이언트가 연 로그인 탭(이미 로그인 세션이 있던 브라우저)이고, 다른 하나는 쿠키 없는 새 비공개 창입니다. 코드 메일이 오지 않아, 처음 쓰는 주소 두 개(그중 하나만 Exclude)에 같은 순간 코드를 요청해 대조했습니다.
끝으로 정책을 원래대로 되돌린 뒤, 같은 탭에서 다시 시도했습니다.
등록을 지운 뒤 약 40초는 연결이 유지됐습니다. 57초 뒤에는 Registration Missing due to: Does not exist in API 가 됐고, 그 뒤로 다시 등록되지 않았습니다. 클라이언트는 오류 없이 Awaiting external log in 만 띄웠습니다.
로그인 세션이 있던 탭은 The page isn't redirecting properly(리디렉션 반복)에서 멈췄습니다. 새 창은 A code has been emailed to … 라고 표시했지만 메일은 오지 않았습니다. 같은 순간 요청한 대조 주소에는 6초 만에 왔고, Exclude 한 주소에는 4분 38초 동안 오지 않았습니다.
Access 요청 로그와 그 사용자의 실패 로그인 기록(failed_logins)에는 막힌 시도가 한 줄도 남지 않았습니다. 코드가 나가지 않아 로그인 이벤트가 생기지 않은 것으로 보입니다.
정책을 되돌리자, 리디렉션을 반복하던 같은 탭에서 코드 없이 곧바로 Success! 가 나왔습니다. 이어서 새 Device ID 로 다시 등록돼 Connected 가 됐습니다.
# 등록 삭제 뒤 5초마다 warp-cli status (KST)
14:16:21 ~ 14:16:51 Status update: Connected Network: healthy
14:16:56 ~ 14:17:06 Status update: Unable Reason: Failed to perform happy eyeballs
14:17:11 Status update: Unable Reason: Registration Missing due to: Does not exist in API
registration show → Error: Missing registration. Try running: "warp-cli registration new"
# 처음 쓰는 주소 두 개에 같은 순간 코드 요청 — 화면은 둘 다 "A code has been emailed to …"
대조 주소 요청 14:56:27 → 메일 14:56:33 도착(6초)
Exclude 주소 요청 14:56:26 → 4분 38초 동안 오지 않음
등록을 지우고 약 1분 뒤 클라이언트는 로그인 창을 열고 Awaiting external log in 만 띄운 채 기다렸습니다. 거부나 오류 문구는 없었습니다.
Gateway 네트워크 정책으로 WARP 의 터널 안 연결 점검을 막을 수 있나요?
막지 못했습니다. 연결 점검 주소를 막는 네트워크 차단 규칙을 켜고 다시 연결해도 세 OS 모두 약 5초 만에 Connected 가 됐고, 문서에 적힌 CF_HOST_UNREACHABLE_CHECK 오류는 나오지 않았습니다. Gateway 가 점검 세션을 실습 규칙보다 먼저 Cloudflare 의 전역 정책 Global Policy - Allow CF Client(모든 계정에 적용되는 기본 허용 규칙)로 허용했습니다.
Windows
안 된다
규칙을 켜고 다시 연결하자 약 5초 만에 Connected. 오류 배너 없음, 클라이언트 로그의 점검 결과는 Ok
macOS
안 된다
Checking network settings 를 거쳐 약 5초 만에 Connected. Gateway 로그에는 점검 세션이 전역 정책 Allow CF Client 의 allow 로 남음
Linux
안 된다
다시 연결하고 약 5초 만에 Connected, 점검 결과 Ok. 같은 규칙은 서버 이름(SNI) 없이 IP 로 직접 보낸 요청은 막음
연결이 안 된다는 문의에서 「Gateway 네트워크 정책이 연결 점검을 막았다」를 먼저 의심할 근거는 약합니다. 이번 실습 조직에서는 IP·SNI 로 건 차단 규칙이 점검을 막지 못했습니다. 방화벽(「사내망에서만 안 붙음」 항목)과 UDP 차단(「26%에서 멈춤」 항목)을 먼저 봅니다.
그래도 연결 점검 주소를 막는 규칙은 두지 않습니다. 문서가 그렇게 권합니다. 전역 정책이 허용하는 것은 SNI 가 *.cloudflareclient.com 인 연결뿐이라, 이름 없이 IP 로 나가는 연결은 사용자 규칙에 그대로 걸립니다(Linux 에서 확인).
시험하지 않은 것: TLS 복호화를 켠 뒤의 HTTP 정책, 포트나 애플리케이션 조건으로 건 규칙. 전역 정책은 문서상 모든 계정에 적용되지만, 확인한 조직은 이 실습 조직 하나입니다.
클라이언트는 연결할 때 터널 안에서 connectivity.cloudflareclient.com(162.159.197.4 · 2606:4700:102::4)으로 연결 점검을 합니다. 공식 방화벽 문서는 이 점검이 터널 안에서 일어나므로 방화벽 허용 목록에 넣을 필요는 없지만, Gateway 를 지나므로 Gateway HTTP·네트워크 정책이 막지 않게 하라고 적고 있습니다. 점검이 실패하면 CF_HOST_UNREACHABLE_CHECK 오류가 납니다. 네트워크 정책으로 정말 막히는지, 막히면 무엇이 보이는지 확인했습니다.
Gateway 네트워크 정책(IP·포트 단위로 거는 정책)에 차단 규칙을 하나 만들었습니다. 조건은 목적지 IP 가 위 두 주소이거나 SNI(TLS 접속 때 보내는 서버 이름)가 connectivity.cloudflareclient.com 인 것이고, 실습 PC 세 대에만 걸리게 기기 조건을 붙였습니다.
규칙을 켠 뒤 45초는 그대로 지켜보고, 이어서 세 PC(클라이언트 2026.7)를 끊었다가 다시 연결해 75초 더 5초마다 상태를 기록했습니다.
클라이언트 로그의 점검 줄과 Gateway 네트워크 로그를 함께 봤습니다. 규칙이 정말 걸려 있는지 보려고 Linux 에서 같은 주소로 직접 요청도 보냈습니다. 조직의 TLS 복호화는 꺼 둔 상태였습니다.
세 OS 모두 오류 없이 약 5초 만에 Connected 로 돌아왔습니다. 클라이언트 로그의 터널 안 점검 결과는 모두 Trace status: Ok 였고, CF_HOST_UNREACHABLE_CHECK 는 나오지 않았습니다.
Gateway 네트워크 로그에는 macOS 의 점검 세션(162.159.197.4:443, SNI connectivity.cloudflareclient.com)이 실습 규칙이 아니라 전역 정책 Global Policy - Allow CF Client 의 allow 로 남았습니다. 공식 문서에 따르면 이 정책은 Cloudflare 서비스를 실수로 막지 않도록 모든 계정에 적용되고, 호스트 이름 *.cloudflareclient.com 을 허용합니다. 실습 규칙(우선순위 1000)보다 먼저 판정됐습니다.
같은 규칙은 Linux 에서 SNI 없이 162.159.197.4 로 직접 보낸 HTTPS 요청을 막았습니다. 규칙과 기기 조건은 제대로 걸려 있었습니다. 반면 SNI 를 넣은 도메인 요청은 같은 전역 정책으로 허용됐습니다. 이 정책은 호스트 이름(SNI)으로 판정하기 때문입니다.
대시보드 로그를 SNI 로 거르면 세 PC 가 다시 연결한 15:55 전후의 점검 세션 4건이 모두 같은 전역 정책으로 허용돼 있습니다(이 화면에서는 어느 PC 의 세션인지 나누지 않았습니다).
# 규칙을 켠 뒤 다시 연결 — Linux warp-cli status (KST)
15:55:06 Status update: Disconnected Reason: Manual Disconnection
15:55:11 ~ 15:56:16 Status update: Connected Network: healthy
# 클라이언트 로그 (UTC)
06:55:06.559Z Advancing to next connection stage: 'Performing connectivity checks'
06:55:06.626Z connectivity_check: Trace status: Ok(TraceResult { … colo: "IAD" … })
# Gateway 네트워크 로그 (UTC)
06:55 macOS 162.159.197.4:443 SNI connectivity.cloudflareclient.com allow ×2 <uuid> (Global Policy - Allow CF Client)
06:57 Linux 162.159.197.4:443 SNI 없음 block <uuid> (실습 차단 규칙)
연결 점검 주소를 막는 네트워크 차단 규칙을 켜고 끊었다가 다시 연결한 뒤의 화면입니다. 오류 배너 없이 약 5초 만에 Connected 로 돌아왔습니다.
TLS 복호화가 켜진 조직에서 클라이언트의 루트 인증서 자동 설치만으로 HTTPS 가 정상이 되나요?
Linux 와 Windows 브라우저는 설치를 켜고 1분 안에 CA 가 들어와 인증서 오류가 풀렸습니다. macOS 는 CA 가 키체인에 들어오고도 신뢰 설정이 실패해 오류가 계속됐고, 조직을 오갈 때 쓰는 MDM configs 배열은 Windows 에서 첫 구성으로 저절로 붙지 않았습니다.
Windows
조건부
CA 가 없을 때 Edge 는 NET::ERR_CERT_AUTHORITY_INVALID. 38초 뒤 CA 가 컴퓨터 루트 저장소에 들어와 Edge 는 정상이 됐지만, curl.exe 는 폐지 확인 실패로 계속 실패
macOS
안 된다
65초 뒤 CA 는 시스템 키체인에 들어왔지만 신뢰 설정이 실패했고, curl 은 계속 인증서 오류(사람 없이 원격으로 조작하던 Mac)
Linux
된다
CA 가 없을 때 curl: (60) 인증서 오류. 50초 뒤 CA 가 시스템 저장소에 들어오자 HTTP 200
복호화를 켜기 전에, 또는 복호화가 켜진 조직으로 단말을 옮기기 전에 그 조직의 루트 CA 를 먼저 배포합니다(Install CA to system certificate store). Linux·Windows 에서는 이것만으로 오류가 풀리는 것을 확인했습니다.
macOS 는 키체인에서 인증서를 Always Trust 로 바꾸거나 MDM 으로 신뢰까지 배포합니다. 공식 문서도 macOS Big Sur 이후에는 클라이언트가 인증서를 자동으로 신뢰하게 할 수 없다고 안내합니다. 이 방법은 이번 실습에서 검증하지 않았습니다.
명령행 도구와 자체 인증서 저장소를 쓰는 앱은 따로 확인합니다. Windows curl.exe 는 CA 가 들어온 뒤에도 폐지 확인에서 멈췄습니다.
MDM 파일을 configs 배열로 바꿔 배포하면 곧바로 연결이 끊길 수 있습니다. 사용자가 Home 의 Configuration 목록에서 고르게 하거나 관리 스크립트로 구성을 지정하고(warp-cli mdm set-config '<display_name>', Windows 에서 확인), 전환 뒤에 남은 옛 등록을 정리합니다(「같은 PC 여러 줄」 항목).
Gateway 의 TLS 복호화를 켜면 Gateway 가 HTTPS 를 풀어 검사한 뒤 자기 인증서(Gateway CA - Cloudflare Managed G1)로 다시 서명해 단말에 보냅니다. 단말이 이 루트 인증서(CA)를 믿지 않으면 HTTPS 가 모두 인증서 오류로 멈춥니다. 기기 관리의 전역 설정 Install CA to system certificate store 를 켜면 클라이언트가 CA 를 OS 인증서 저장소에 스스로 넣습니다. 한 고객사가 복호화가 켜진 다른 조직으로 단말을 옮기는 계획을 세우면서 「옮기기 전에 그 조직의 CA 를 먼저 깔아야 한다」는 순서를 정했고, 이 순서가 맞는지 확인했습니다.
세 OS 실습 PC(클라이언트 2026.7)가 붙은 실습 조직에서 TLS 복호화를 5분 12초 동안 켰습니다. 실습 PC 가 아닌 단말과 원격 관리 경로는 먼저 Do Not Inspect(검사 제외) 규칙으로 뺐습니다.
CA 가 없는 채로 curl https://example.com 을 실행했고 Windows 는 Edge 로도 열었습니다. 이어서 Install CA to system certificate store(API root_certificate_installation_enabled)를 켜고 5초마다 OS 인증서 저장소를 확인했습니다. 끝나고 두 설정을 끈 뒤 CA 가 지워지는지도 봤습니다.
Windows 에서는 MDM 파일에 구성 두 개(configs 배열: 서비스 토큰으로 등록하는 구성과 사용자가 로그인하는 구성, 둘 다 같은 실습 조직)를 넣고 화면을 봤습니다. 두 번째 실습 조직이 없어 서로 다른 조직 사이의 전환과 좌석 이중 소비는 해 보지 못했습니다. Windows PC 는 공식 지원 OS 가 아닌 Windows Server 2025 입니다.
CA 가 없으면 세 OS 모두 HTTPS 가 인증서 오류로 멈췄고, 인증서 발급자는 Gateway CA 로 바뀌어 있었습니다. 설치를 켜자 Windows 38초, Linux 50초, macOS 65초 뒤에 클라이언트가 CA 를 넣었습니다(클라이언트 로그 기준). Linux 의 curl 은 HTTP 200, Windows Edge 는 정상 페이지가 됐습니다. 다만 Windows curl.exe 는 CRYPT_E_NO_REVOCATION_CHECK(인증서 폐지 여부 확인 실패)로 계속 실패했습니다.
macOS 는 CA 를 시스템 키체인에 넣었지만 신뢰 설정에서 SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible. 로 실패했고, curl 은 계속 오류였습니다. 이 Mac 은 사람 없이 원격으로만 조작하던 상태였습니다. 사람이 앉아 있는 Mac 과 Safari 로는 확인하지 않았습니다.
설정을 끄자 세 OS 모두 37~63초 뒤에 클라이언트가 CA 를 지웠습니다. Linux 는 /etc/ssl/certs 에 대상이 없는 링크 2개가 남았습니다.
MDM 파일을 구성 두 개로 바꾸자 클라이언트가 첫 구성으로 붙지 않고 Registration Missing due to: Account Mismatch 상태에서 처음 쓰는 사람용 화면을 띄웠습니다. 공식 문서에는 첫 구성으로 로그인하라는 안내가 나온다고 적혀 있습니다. warp-cli mdm set-config 로 구성을 고르자 5~6초 만에 Connected 가 되고 Home 에 Configuration 목록이 생겼습니다. 구성을 바꿀 때마다 새 등록(새 Device ID)이 생겼고, 옛 등록은 서버에 남았습니다.
# 복호화 켬, CA 없음 — Linux (17:17 KST)
curl: (60) SSL certificate OpenSSL verify result: self-signed certificate in certificate chain (19)
issuer=… O=Cloudflare, Inc., … CN=Gateway CA - Cloudflare Managed G1 <uuid>
# 루트 인증서 설치를 켠 뒤 클라이언트 로그 (UTC, 08:18:16 에 켬)
08:18:54.414Z root_ca: Installing new root CA certificate total=1 ← Windows
08:19:06.644Z root_ca: Installing new root CA certificate total=1 ← Linux
08:19:21.308Z WARN root_ca: Failed to automatically trust certificates installed err=InstallFailed(… "SecTrustSettingsSetTrustSettings: The authorization was denied since no user interaction was possible.") ← macOS
# Windows, 구성 2개 MDM 을 넣은 직후
Status update: Unable / Reason: Registration Missing due to: Account Mismatch
조직의 TLS 복호화를 켜고 루트 CA 가 없는 상태에서 curl 로 https://example.com 을 열자 self-signed certificate in certificate chain (19) 오류(exit 60)로 멈췄습니다. 화면 시각은 UTC 입니다.
WARP 를 켠 두 단말이 서로의 가상 IP(100.96.x.x)로 바로 통신할 수 있나요?
조직의 WARP-to-WARP 설정을 켜고, 두 단말의 Split Tunnel 이 가상 IP 대역을 터널에 태우고, 받는 단말의 방화벽이 열려 있을 때 됐습니다. 설정이 꺼져 있거나 방화벽이 막으면 오류 없이 시간 초과로만 보였습니다.
Windows
조건부
보낼 때는 설정을 켜자 5초 안에 200(0.05초), 켜기 전에는 8초 시간 초과. 받을 때는 Windows 방화벽에 막혀 8초 시간 초과였고, 100.96.0.0/12 허용 규칙을 넣자 0.03초에 응답
Linux
조건부
받을 때는 따로 연 것 없이 됐고(ufw 꺼짐), 로그의 출발지는 Windows 가상 IP 100.96.0.6. 보낼 때는 Windows 쪽 방화벽 규칙을 넣은 뒤에야 응답
단말끼리 안 붙으면 세 가지를 차례로 봅니다. 조직 연결 설정의 WARP-to-WARP(API offramp_warp_enabled, 대시보드 메뉴 위치는 이번에 확인하지 않았습니다), 두 단말 Split Tunnel 의 Exclude 에 100.64.0.0/10 이 남았는지(warp-cli settings), 받는 단말의 방화벽입니다. 셋을 모두 맞춘 상태에서 되는 것은 실습 PC 에서 확인했습니다.
Windows 가 받는 쪽이면 100.96.0.0/12 에서 오는, 필요한 포트만 여는 인바운드 규칙을 넣습니다(New-NetFirewallRule -Direction Inbound -Protocol TCP -LocalPort 8088 -RemoteAddress 100.96.0.0/12 -Action Allow, 확인함).
Pulumi(cloudflare provider 6.20)로 Exclude 목록 중간의 한 줄을 뺐다가 되돌리자, 주소는 맞았지만 설명(description)이 한 칸씩 밀려 붙었습니다. preview --refresh 로도 보이지 않았습니다. 경로에는 영향이 없었고, API 로 시험 전 값을 다시 넣어 맞췄습니다.
WARP-to-WARP 는 WARP(Cloudflare One Client)를 켠 단말끼리 Cloudflare 를 거쳐 서로의 가상 IP(단말마다 하나씩 받는 100.96.0.0/12 대역 주소)로 통신하게 하는 기능입니다. 원격 지원이나 단말끼리 직접 붙는(P2P) 앱에 씁니다. 조직의 연결 설정(API 필드 offramp_warp_enabled)으로 켜고 끕니다.
Linux 와 Windows 실습 PC 두 대로 했습니다. 두 PC 는 같은 클라우드 사설망에 있지만 보안 그룹(클라우드 방화벽)이 들어오는 연결을 막아, Cloudflare 를 거친 가상 IP 경로로만 닿습니다.
먼저 두 단말 프로필의 Split Tunnel Exclude 목록(기본 16줄)에서 가상 IP 대역을 품은 100.64.0.0/10 한 줄을 뺐습니다. 그다음 Linux 에 8088 포트 웹 서버를 띄우고, 설정을 끈 채와 켠 채로 Windows 에서 curl 로 접속했습니다.
반대로 Windows 에 8088 수신기를 띄우고 Linux 에서 접속했습니다. Windows 방화벽 규칙(100.96.0.0/12 에서 오는 TCP 8088 허용)을 넣기 전과 뒤를 비교했습니다.
설정을 다시 끄고 한 번 더 접속해 본 뒤, 방화벽 규칙과 Split Tunnel 을 원래대로 되돌렸습니다.
설정이 꺼져 있으면 Split Tunnel 을 맞춰 둬도 8초 시간 초과였습니다. 켠 지 5초 안의 첫 시도에서 200(0.05초)이 왔고, 다시 끄자 11초 뒤의 시도가 시간 초과였습니다. 단말을 다시 연결할 필요는 없었습니다.
받는 쪽이 본 출발지는 상대 단말의 가상 IP 였습니다(Linux 로그 100.96.0.6, Windows 응답 you are 100.96.0.12).
Windows 는 WARP 어댑터(CloudflareWARP)가 Public 네트워크로 잡히고 들어오는 연결의 기본 동작이 차단이라, 규칙을 넣기 전에는 8초 시간 초과였습니다. Linux 는 호스트 방화벽(ufw)이 꺼져 있어 따로 열지 않아도 됐습니다.
설정이 꺼졌을 때도, 방화벽이 막았을 때도 오류 메시지는 없고 시간 초과뿐이었습니다. Exclude 에 100.64.0.0/10 을 남긴 채 설정만 켜 보는 대조는 하지 않았습니다.
# Windows → Linux 가상 IP (KST)
18:42:52 설정 꺼짐 http=000 t=8.00 rc=28
18:43:29 켠 지 5초 hello from ubuntu-pc over WARP-to-WARP (S30) [http=200 t=0.049627]
# Linux 서버 로그 (UTC) — 출발지는 Windows 가상 IP
100.96.0.6 - - [24/Sep/2026 09:43:29] "GET / HTTP/1.1" 200 -
# Linux → Windows 가상 IP (KST)
18:44:25 방화벽 규칙 없음 http=000 t=8.00 rc=28
18:45:04 규칙 넣은 뒤 hello from <windows-host> over WARP-to-WARP (S30) - you are 100.96.0.12:32978 [http=200 t=0.027796]
# 다시 끈 뒤 Windows → Linux (KST)
18:48:47 http=000 t=5.00 rc=28
WARP-to-WARP 를 켠 뒤 Linux 단말의 가상 IP(100.96.0.12:8088)로 접속하자 Linux 의 웹 서버가 답했습니다. 켜기 전에는 같은 접속이 8초 시간 초과였습니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › Traffic › Waiting Room웨이팅 룸 목록과 만들기 단추, 검색 로봇을 줄 세우지 않는 SEO Crawler Bypassing 설정이 있습니다.
① 웨이팅 룸을 설계합니다
줄을 세워 오리진을 지킵니다 — 막는 게 아니라 「흘려보내는」 기능입니다
② 이렇게 흐릅니다 동시 접속이 몰렸을 때 몇 명이 줄을 서고 얼마나 기다리나
왼쪽 값을 정하고 대기열 계산을 누르면,
몇 명이 바로 들어가고 몇 명이 얼마나 기다리는지 나옵니다.
③ 진짜 웨이팅 룸에 들어가 봅니다
계산이 아니라 실제 Cloudflare 대기열입니다
「지금 확인」을 누르면 실제로 쏴 봅니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
도메인 › SSL/TLS › Edge Certificates방문자 쪽 인증서 설정 화면입니다. HSTS 와 최소 TLS 버전도 여기서 정합니다.
사례 인증서 갱신 안내 메일이 몇 주째 반복해서 옵니다실제로 1번 겪은 일 · 가장 최근 2026-09-13 · 마지막 수정 2026-10-15
이렇게 보입니다「조치 필요: 도메인을 검증하세요」 메일이 같은 도메인으로 계속 오고 간격이 하루로 좁아지기도 합니다. 서비스는 멀쩡해 보여도 만료일이 지나면 방문자가 연결하지 못합니다.
왜 그런가요CNAME 방식(부분 설정) 도메인은 권한 DNS 가 다른 곳에 있어 Cloudflare 가 검증 레코드를 대신 넣지 못합니다. 갱신마다 사람이 TXT 를 등록해야 해서, 한 번 밀리면 만료까지 밀립니다. 도메인이 이미 Cloudflare 로 향하지 않아도 같은 메일이 오는데, 이때는 권한 DNS 가 응답하지 않아 TXT 를 넣는 것 자체가 불가능합니다. 메일 본문이 그 사유를 세 가지 중 하나로 알려 줍니다.
이렇게 합니다반복을 끊는 방법은 둘입니다. ① 위임 DCV: 일회성 CNAME 을 한 번 넣으면 이후 갱신은 Cloudflare 가 검증 레코드를 배치합니다. 부분 설정을 유지할 때 씁니다. ② 전체 설정 전환: 네임서버를 옮기면 검증이 완전히 자동입니다. 어느 쪽인지는 짐작하지 말고 도메인 상태 조회에서 설정 종류와 권한 네임서버로 확인합니다. 「네임서버를 옮겼다」고 알아도 부분 설정으로 남은 경우가 있습니다. 토큰은 재발송마다 바뀔 수 있으니 등록 직전 최신 메일의 값을 씁니다. 바뀌는 때는 인증서 묶음이 새로 발급될 때라, 며칠째 같은 값도 정상입니다.
여기서 해 보기 — 쓰지 않는 도메인을 일시정지하거나 요금제를 낮춰도 메일은 계속 옵니다. 도메인이 계정에 남으면 인증서도 남습니다. 「정리했는데 또 온다」면 삭제였는지 일시정지였는지부터 확인합니다.
사례 레코드를 넣었는데 도메인이 켜지지 않거나, 켜졌는데 인증서가 안 나옵니다실제로 3번 겪은 일 · 가장 최근 2026-09-22 · 마지막 수정 2026-10-15
이렇게 보입니다새 도메인에 안내받은 레코드를 넣었는데 절반만 진행됩니다. 도메인이 계속 「대기」이거나, 활성인데 인증서가 며칠째 안 나옵니다.
왜 그런가요안내되는 레코드는 두 종류입니다.① 소유 검증 TXT: 통과해야 도메인이 활성화됩니다. ② 인증서 검증용 CNAME: 통과해야 SSL 인증서가 나옵니다. 이름도 넣는 위치도 달라서 하나만 넣으면 나머지 절반이 조용히 멈추고, 오류 화면이 없어 「다 넣었다」고 믿기 쉽습니다. 여러 도메인을 한꺼번에 올릴 때 특히 샙니다. 도메인 3개면 6건인데, 표를 도메인 단위로 읽다 한 종류를 통째로 빠뜨립니다.
이렇게 합니다넣기 전에 건수(도메인 수 × 2)를 세십시오. 증상으로 빠진 쪽이 갈립니다. 활성화가 안 되면 TXT, 활성인데 인증서가 없으면 CNAME 입니다. 전환 예정일이 있으면 역산하십시오. 둘 다 DNS 전파를 기다리므로 전환 당일 아침에 넣으면 인증서 없이 전환되어 방문자에게 보안 경고가 뜹니다. 값은 도메인마다 달라 복사해 쓰면 통과하지 않습니다. 자동 안내를 「무시하라」고 들었다면 다시 물으십시오. 「넣지 않아도 된다」가 아니라 「지금은 넣지 말라」라면 언제 누가 넣을지 미정이고, 그대로 두면 전환 시각에 도메인이 대기로 들어갑니다. 요청서에 몇 종류를 적었는지도 점검하십시오. 상대는 받은 목록대로 넣으므로 한 종류만 적으면 완벽히 일하고도 절반만 끝낸 채 「등록 완료」로 회신합니다. 회신을 받으면 두 종류를 각각 조회하십시오. 상대 말이 아니라 조회가 답입니다.
여기서 해 보기 — 등록 뒤에는 도메인 상태와 인증서 상태를 따로 확인하십시오. 별개 절차라 한쪽만 보면 「다 됐다」로 오해합니다. 조회만으로 되며 설정을 바꿀 필요가 없습니다.
사례 검증 레코드는 전부 맞는데 인증서 갱신이 「검증 대기」에서 멈춰 있습니다실제로 1번 겪은 일 · 가장 최근 2026-09-17 · 마지막 수정 2026-10-15
이렇게 보입니다위임 DCV 를 걸었고 위임용 CNAME·TXT 값도 Cloudflare 게시 값과 같은데, 갱신이 며칠째 검증 대기입니다. 공개 DNS 조회는 될 때와 안 될 때가 섞이고, 될 때는 토큰까지 정상입니다.
왜 그런가요이름이 읽히지 않는 순간이 섞이면 검증은 실패합니다. 인증기관은 한 번 묻고, 그 한 번이 실패에 걸리면 갱신이 멈추며 재시도도 확률입니다. 그러니 「무엇이 틀렸나」가 아니라 「왜 읽히지 않나」를 찾습니다. 흔한 원인은 둘입니다. ① 권한 네임서버 일부만 존을 가짐: 그 대를 고른 조회만 거절되고, 두 대 중 한 대면 실패율이 대략 절반입니다. 리졸버는 「권한 있는 서버에 닿을 수 없다」는 확장 오류와 함께 거절한 주소를 알려 주는 경우가 많습니다. ② 맨 윗단(apex) CNAME: 맨 윗단은 NS·SOA 를 반드시 갖는데 CNAME 은 같은 이름의 다른 레코드와 공존할 수 없어 규격 위반입니다. 둘을 섞지 마십시오. 맨 윗단 CNAME 은 정리할 문제지만 이번 실패의 원인이라는 뜻은 아닙니다. 같은 네임서버에서 같은 모양의 맨 윗단 CNAME 을 가진 다른 이름이 멀쩡히 조회되는 경우가 실제로 있습니다.
이렇게 합니다① 실패율을 셉니다. 같은 질의를 10회 이상 반복해 절반쯤 실패하면 권한 서버 일부만 존을 가진 쪽, 매번 실패하면 이름 자체가 성립하지 않는 쪽입니다. ② 확장 오류 코드를 읽습니다. 거절한 서버 주소가 위임 네임서버 중 하나면 범인이 좁혀집니다. 코드 번호의 뜻은 짐작하지 마십시오. 번호와 이름은 RFC 8914 등록부에 정해져 있고, 전달받은 설명이 실제와 다른 경우가 있습니다. ③ 대조군을 만듭니다. 같은 네임서버의 다른 이름이 멀쩡하면 그 이름만의 문제입니다. 이 비교가 잘못된 원인 지목을 막습니다. ④ 권한 네임서버에 직접 묻습니다. 공개 리졸버는 어느 서버를 골랐는지 감춥니다. 서버마다 따로 질의하면 존이 없는 대가 드러나고 추론이 실측이 됩니다. ⑤ 그다음 고칩니다. 존이 없는 서버에 존을 올리는 것이 본 조치이고, 맨 윗단 CNAME 정리는 별건으로 함께 합니다. 맨 윗단으로도 트래픽을 받아야 하면 CNAME 대신 A 레코드를 씁니다.
여기서 해 보기 — 판정은 도구가 아니라 횟수와 대조군이 가릅니다. 한 번 실패로 원인을 정하지 말고, 열 번 반복해 비율을 보고 같은 네임서버의 다른 이름과 비교한 뒤, 위임된 서버마다 직접 물어보십시오.
사례 와일드카드 레코드를 넣었는데 일부 주소에서만 보안 경고가 뜹니다실제로 1번 겪은 일 · 가장 최근 2026-09-22 · 마지막 수정 2026-10-15
이렇게 보입니다한 단계 아래 이름을 한꺼번에 받으려고 별표(와일드카드) DNS 레코드를 만들었습니다. 처음 만든 주소는 정상인데 나중에 쓰기 시작한 주소에서만 인증서 경고가 뜹니다. DNS 는 응답하고 있습니다.
왜 그런가요레코드가 와일드카드인 것과 인증서가 와일드카드인 것은 다릅니다. CNAME 방식(부분 설정)에서는 기본 인증서가 프록시된 호스트마다 발급되고, 서브도메인 깊이와 무관하게 자동 발급·갱신됩니다. 그래서 레코드를 만들어 프록시한 이름은 문제가 없습니다. 문제는 아직 레코드가 없는 이름입니다. 별표가 DNS 응답은 만들지만 프록시 호스트로 등록된 적이 없어 그 이름을 덮는 인증서가 없고, «연결은 되는데 인증서만 안 맞는» 모양이 됩니다. 이름이 고정된 소수인지 계속 늘어나는지를 묻지 않고 구성하면 필요 없는 인증서 상품을 사거나, 새 이름마다 사람이 손대는 구조가 됩니다.
이렇게 합니다먼저 그 아래 이름이 몇 개이고 늘어나는지 확정하십시오.① 고정된 소수: 이름마다 레코드를 만들고 프록시를 켜면 기본 인증서가 자동으로 붙어 추가 구매가 필요 없습니다. ② 동적으로 늘어남: 별표를 덮는 상위 인증서 상품을 따로 발급하고, 부분 설정 존이면 위임 DCV 도 걸어 검증·갱신이 사람 손을 타지 않게 합니다. 별표는 맨 앞 한 레이블에만 유효하고, 정확한 이름의 레코드가 따로 있으면 그 레코드가 우선합니다. 「한 주소만 다르게 동작한다」면 대개 개별 레코드가 이미 있는 경우입니다.
여기서 해 보기 — 조회만으로 갈립니다. 문제 이름과 정상 이름을 각각 열어 어떤 이름이 적힌 인증서가 오는지 비교하십시오. 정상 쪽은 그 이름 전용, 문제 쪽은 그 이름이 없는 인증서가 옵니다.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
My Profile › API Tokens › Create Token › Custom token토큰은 필요한 권한·계정만 고르고, Client IP Address Filtering 과 TTL 로 쓸 곳과 기한을 좁힙니다.계정 › Notifications › Create an alert만료·유출·공격 탐지 같은 알림을 제품별로 골라 받습니다.
사례 요청받은 이름으로 설정을 찾는데 아무 데도 없습니다실제로 2번 겪은 일 · 가장 최근 2026-09-22 · 마지막 수정 2026-10-15
이렇게 보입니다「○○ 에 이 IP 를 추가해 주세요」처럼 이름 하나만 달린 요청이 옵니다. 도메인별 보안 룰과 허용 목록을 뒤져도 걸리는 것이 없어, 비슷한 것을 골라 손대고 싶어집니다.
왜 그런가요설정은 두 층에 삽니다. 도메인(존)마다 붙는 것과 계정 전체에 붙는 것입니다. 요청자는 업무 시스템의 이름을 옮겨 적을 뿐 어느 층인지는 적지 않습니다. 특히 API 접근 열쇠의 「사용 가능한 IP 제한」은 계정 층인데, 흔히 「IP 접근 제어」라 부르고 도메인 쪽에도 같은 이름의 기능이 있어 말로는 갈리지 않습니다. 이름까지 겹치면 위험이 커집니다. 요청 번호를 이름 뒤에 붙이는 관행이 있으면 앞부분이 같은 열쇠가 여러 개 생기고, 틀린 쪽에 대역을 열어도 오류가 없습니다. 조용히 적용되고 요청한 쪽은 여전히 막힙니다.
이렇게 합니다① 이름보다 어느 층인지를 먼저 가르십시오. 도메인별 보안 룰과 허용 목록에 하나도 안 걸리면 다른 층에 있다는 신호이니 계정 층의 접근 열쇠 목록을 봅니다. ② 비슷한 이름이 둘 이상이면 멈추고 되물으십시오. 만든 날짜와 만든 사람까지 붙여 확인받습니다. 변경 이력에 있어 조회만으로 됩니다. ③ 요청 값이 이미 들어가 있지 않은지 보십시오. 같은 대역이 다른 쪽에 이미 있으면 「추가」가 아니라 대상이 바뀐 것일 가능성이 높고, 그대로 넣으면 양쪽 모두 열린 채 남습니다. 대역은 지울 수 있어도 그 사이 오간 요청은 되돌릴 수 없으니 대상 확정 전에는 넣지 않는 편이 항상 쌉니다.
여기서 해 보기 — 조회만으로 갈립니다. 요청받은 이름을 도메인별 보안 룰·허용 목록·계정 접근 열쇠 세 곳에서 찾아, 한 곳에만 걸리면 그것이 대상이고 두 곳 이상이면 되묻습니다.
사례 특정 도메인만 볼 수 있게 권한을 주려는데 그 항목이 목록에 없습니다실제로 1번 겪은 일 · 가장 최근 2026-09-22 · 마지막 수정 2026-10-15
이렇게 보입니다「이 사람들에게 이 도메인의 이 기능만 보여 주자」는 요청이 옵니다. 다른 기능처럼 될 줄 알았는데 도메인 단위 역할 목록에 그 기능이 없고 계정 단위에만 있습니다.
왜 그런가요설정이 사는 층이 기능마다 다릅니다. 방화벽 규칙·DNS 처럼 도메인에 붙는 기능은 도메인별로 권한을 쪼갤 수 있지만, 계정에 붙는 기능은 자원이 도메인에 속하지 않아 쪼갤 수 없습니다. 대표가 봇 방지 위젯입니다. 계정에서 만들고 키를 받아 아무 사이트에나 붙이는 구조라 「어느 도메인의 위젯」이라는 개념이 없고, 읽기 권한도 계정 단위로만 줍니다. 요청하는 쪽에는 두 종류가 나란히 비슷한 이름으로 보여 「왜 이것만 안 되나」로 받아들이기 쉬우니, 되는지 안 되는지 먼저 갈라 주는 것이 답변의 절반입니다.
이렇게 합니다① 그 기능이 어느 층인지 확인합니다. 가장 빠른 방법은 API 주소 모양입니다. 계정 아래에만 있으면 도메인별로 쪼갤 수 없습니다. ② 이유와 대안을 함께 줍니다. 「불가능합니다」로 끝내면 요청한 쪽은 이유를 모른 채 남습니다. 계정 단위 읽기 권한이 현실적 대안이면 제시하되 ③을 반드시 덧붙입니다. ③ 계정 단위로 주면 그 계정의 그 기능 전체가 보입니다. 도메인을 나누려던 이유가 조직 분리나 최소 권한 원칙이었다면 이 대안은 그 목적을 충족하지 못합니다. 그래도 나눠야 하면 남는 길은 계정 분리입니다. 비용과 운영 부담이 따르니 권한 요청 단계에서 꺼내야 합니다. 나중이면 구조가 굳은 뒤입니다.
여기서 해 보기 — 조회만으로 확인됩니다. 역할 목록을 계정 단위와 도메인 단위에서 각각 열어 그 기능이 어느 쪽에 있는지 보십시오.
대시보드에서는 이 화면입니다실습장 랩 계정 · 2026-10-15 캡처 · 눌러서 크게 보기
계정 › Support케이스 열기, 내 케이스, 채팅, 긴급 전화(Enterprise), 악용 신고가 한 화면에 있습니다.Support › Submit a case증상과 빈도, UTC 시각, 재현 순서를 적고 영향 등급과 대상 존을 고릅니다.
사례 지원 케이스가 「종료」로 바뀌었습니다. 요청한 설정은 켜진 걸까요실제로 3번 겪은 일 · 가장 최근 2026-09-22 · 마지막 수정 2026-10-15
이렇게 보입니다기능을 켜 달라고 연 지원 케이스에 「이 케이스는 종료되었습니다」 알림이 옵니다. 끝났다는 뜻으로 읽히지만, 화면을 열면 설정이 그대로이거나 무엇이 켜져 있는지 알 수 없습니다.
왜 그런가요「종료」는 대화가 끝났다는 뜻이지 설정이 바뀌었다는 뜻이 아닙니다. 담당자가 답하면 「해결됨」이 되고, 정해진 시간 안에 되묻지 않으면 아무도 손대지 않아도 자동으로 「종료」됩니다. 답이 「요청 대상이 아니고 화면에서 직접 켜는 것입니다」나 「이미 하나 있습니다」일 때 특히 헷갈립니다. 설정은 아무것도 바뀌지 않았고, 「이미 있다」는 것이 무엇인지도 확인되지 않은 채 종료됩니다. 종료된 케이스는 되살릴 수 없어 새 케이스를 열어 옛 번호를 인용하고 맥락을 다시 설명해야 합니다.
이렇게 합니다① 종료 알림을 완료로 읽지 마십시오. 설정은 화면이나 조회로 직접 확인해야 확정됩니다. ② 「이미 있습니다」를 받으면 무엇이 있는지까지 받으십시오. 이름과 적용 범위를 확인하지 않으면 원하던 것인지 끝내 모릅니다. ③ 「해결됨」이 되면 자동 종료 시계가 돕니다. 확인할 것이 남았다면 그 창 안에 되물으십시오. 지나면 새 케이스밖에 없습니다. 닫기 전에 「무엇을 요청했고, 무엇이 바뀌었고, 무엇이 안 바뀌었나」 한 줄을 남기십시오. 없으면 몇 주 뒤 같은 질문을 처음부터 합니다. 종료 문장이 「고쳤습니다」가 아니라 「방법을 드렸습니다」면 아직 아무것도 바뀌지 않은 것이고, 그 방법이 통하는지는 받은 쪽이 확인해야 합니다. 창이 열려 있을 때 한 번 돌려 보십시오.
여기서 해 보기 — 종료된 케이스가 다룬 기능을 지금 화면에서 직접 열어 보십시오. 조회만으로 되며, 「켜져 있다고 생각했는데 아니었다」가 여기서 드러납니다.
사례 남의 답을 기다리는 중인데 지원 케이스가 자꾸 「곧 종료됩니다」라고 합니다실제로 1번 겪은 일 · 가장 최근 2026-09-22 · 마지막 수정 2026-10-15
이렇게 보입니다지원 케이스에서 요청받은 자료를 다른 쪽(고객사·협력사·다른 팀)에 넘겨 기다리는 중인데, 「아직 계신가요? 며칠 뒤 자동으로 해결됨 처리됩니다」 알림이 옵니다. 기다릴수록 케이스가 닫히는 쪽으로 밀립니다.
왜 그런가요자동 종료 시계는 일이 막힌 곳이 아니라 당신이 답하지 않은 기간을 셉니다. 지원 쪽은 마지막으로 공을 넘긴 것이 자기들이라, 회신이 없으면 「해결됐거나 관심이 식었다」로 봅니다. 자료를 구하는 중인지 잊은 것인지 시계는 구분하지 않습니다. 한 번 답하면 되감기지만 꺼지지 않고 다시 돌아, 상대가 두 주를 끌면 같은 알림을 여러 번 받습니다. 더 조용한 함정은 「기다리는 중」이라는 답이 자료 제출로 오해되는 것입니다. 시계는 멈춰도 요청받은 것은 미제출이라 진행은 하루도 나아가지 않습니다.
이렇게 합니다① 기다리는 중이라고 그 창 안에 적으십시오. 한 줄이면 시계가 되감깁니다. 무엇을 누구에게 넘겼고 언제쯤 받을지까지 적으면 상대도 알림 주기를 조정해 줍니다. ② 상태 보고와 자료 제출을 구분하십시오. 「기다리는 중」은 시계만 멈추고 요청을 채우지 않습니다. ③ 지금 낼 수 있는 것부터 내십시오. 요청 자료 중에는 상대 회신 없이 우리 관측만으로 뽑히는 것이 섞인 경우가 많고, 그것을 내야 실제로 앞으로 갑니다. ④ 오래 걸릴 것 같으면 미리 말하십시오. 「2주쯤 걸릴 예정」이라고 적는 편이 매번 경고에 답하는 것보다 품이 적게 듭니다.
여기서 해 보기 — 열린 케이스 목록에서 마지막 코멘트를 쓴 사람을 보십시오. 상대 쪽이고 며칠이 지났다면 이미 시계가 돌고 있습니다.