<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>WARP · 인재덕</title>
    <link>https://jared.lynskey.co.nz/ko/tags/warp/</link>
    <description>소프트웨어 엔지니어링과 개발 조직 리더십에 대한 이야기. 성과 리뷰, 채용과 팀 확장, CI/CD, 데브옵스 모니터링, 에이전틱 AI. 서울에서 씁니다.</description>
    <generator>Hugo</generator>
    <language>ko</language>
    <copyright>© 2026 인재덕</copyright>
    <lastBuildDate>Thu, 01 Oct 2026 00:00:00 +1200</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ko/tags/warp/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>나 혼자 쓰는 사설 네트워크: Cloudflare WARP, 터널, 그리고 제가 개발하는 기계들</title>
      <link>https://jared.lynskey.co.nz/ko/posts/2026/2026-10-01-cloudflare-warp-dev-network/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ko/posts/2026/2026-10-01-cloudflare-warp-dev-network/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>WARP</category>
      <category>Zero Trust</category>
      <category>Cloudflare Tunnel</category>
      <category>Networking</category>
      <category>SSH</category>
      <category>DevOps</category>
      <category>AI Agents</category>
      <category>Ollama</category>
      <category>Mobile Development</category>
      <category>Expo</category>
      <category>IPv6</category>
      <description>개발은 MacBook Air로 하지만, 무거운 작업(로컬 모델, 파인튜닝, k3s 클러스터, 에이전트 실행)은 공개 이름이 없는 사무실 워크스테이션에서 돌고, 테스트는 대개 5G에 붙어 있는 폰으로 합니다. Cloudflare WARP와 Cloudflare Tunnel 두 개로 이 셋을 사설 네트워크 하나로 묶었습니다. 다른 어디에서도 쓰지 않는 대역의 고정 주소, Access 로그인 뒤에 있는 dev 서버용 공개 HTTPS, 그리고 열린 인터넷에는 절대 닿지 않는 대시보드까지요. Caddy로 `.test` 이름에 로컬 HTTPS를 붙이고 SwiftBar 메뉴로 무엇이 살아 있는지 보는 것부터, 주소가 왜 198.18.0.x와 fd00:198:18::x인지, 그리고 오는 길에 제 발목을 잡은 네 가지까지, 어떻게 짜여 있는지 정리했습니다.</description>
      <content:encoded><![CDATA[<p>제 작업 환경은 좀처럼 한곳에 모이지 않는 기계 세 대입니다. 어디든 들고 다니는 MacBook Air가 있습니다. 사무실에는 devbox라는 워크스테이션이 있는데, 제대로 된 GPU가 달려 있고 Ollama, 파인튜닝용 Unsloth Studio, 자체 컨테이너 레지스트리를 갖춘 작은 k3s 클러스터, 그리고 긴 작업을 붙잡고 씨름하도록 놔둔 에이전트가 무엇이든 돌아갑니다. 그리고 폰이 있습니다. 제가 만드는 앱이 실제로 쓰이는 곳이고, 하루 대부분을 wifi가 아니라 모바일 데이터로 보내죠.</p>
<p>오랫동안 이 셋은 서로 제대로 이야기하지 못했습니다. 노트북은 사무실 LAN에 있을 때만 devbox에 닿았고 그 밖에서는 못 닿았습니다. 폰은 노트북과 같은 wifi에 있을 때만 노트북의 dev 서버에 닿았고, 그것도 제가 <code>0.0.0.0</code>에 바인딩하는 걸 잊지 않고 그날 아침 DHCP가 Mac에 준 주소를 직접 쳐 넣었을 때 얘기였습니다. 워크스테이션에서 재미있는 건 전부, 그러니까 Ollama API, Traefik 대시보드, 트레이스, 레지스트리는 일부러 localhost나 클러스터 네트워크에 바인딩돼 있었습니다. 어느 것도 인터넷에 있을 물건이 아니니까요.</p>
<p>이 글은 Cloudflare WARP와 Cloudflare Tunnel 한 쌍으로 이것들을 어떻게 이었는지, 그게 AI 기능과 모바일 앱을 만드는 방식을 어떻게 바꿨는지, 그리고 저한테 말도 없이 고장 난 몇 가지에 대한 이야기입니다.</p>

<h2 class="relative group">제가 실제로 원한 것
    <div id="제가-실제로-원한-것" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a0%9c%ea%b0%80-%ec%8b%a4%ec%a0%9c%eb%a1%9c-%ec%9b%90%ed%95%9c-%ea%b2%83" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>요구사항으로 적으면 짧습니다.</p>
<ol>
<li>노트북은 씬 클라이언트여야 합니다. GPU나 많은 RAM, 몇 시간의 실행 시간이 필요한 일은 전부 devbox에서 돌아야 하고, 노트북은 사무실에서든 집에서든 카페에서든 똑같은 방식으로 거기에 닿아야 합니다.</li>
<li>폰은 어디서든, 모바일 데이터로, 켜고 끄는 걸 기억해야 하는 VPN 없이 제 dev 서버와 셸에 닿아야 합니다.</li>
<li>시스템이 어떻게 돌아가는지 보는 데 쓰는 것들, 즉 대시보드, 트레이스, <code>docker info</code>, 모델 목록은 저만 닿을 수 있어야 하고, 공개 URL을 절대 가져서는 안 됩니다.</li>
<li>인바운드 포트는 없습니다. 사무실 라우터에도, 집 라우터에도, 어디에도요.</li>
</ol>
<p>네 번째 요구사항이 예전의 답, 즉 포트 포워딩과 동적 DNS 이름을 배제합니다. 잔재주도 상당수 배제되죠. devbox와 노트북 둘 다 제가 통제하지 못하는 NAT 뒤에 있고, 노트북은 몇 시간마다 다른 NAT 뒤에 있습니다.</p>

<h2 class="relative group">이전: SSH 터널 저장소
    <div id="이전-ssh-터널-저장소" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9d%b4%ec%a0%84-ssh-%ed%84%b0%eb%84%90-%ec%a0%80%ec%9e%a5%ec%86%8c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>첫 버전은 제가 Automatic Tunnel이라고 부른 작은 저장소였고, 지금도 바닥층으로 남아 있습니다. 노트북에서 &ldquo;SSH로 닿는 것들에 깔끔한 이름 붙이기&rdquo; 문제만 풀고, 그 이상은 하지 않습니다. dnsmasq가 <code>*.test</code>에 <code>127.0.0.1</code>로 답하고, Caddy가 호스트명에 따라 로컬 포트로 라우팅하면서 <code>tls internal</code>로 브라우저를 만족시키고, autossh launch agent 묶음이 SSH 포워드를 열어 두었다가 끊기면 다시 잇습니다. 호스트명이 그 물건이 어디 사는지 말해 줍니다.</p>
<table>
	<thead>
			<tr>
					<th>접미사</th>
					<th>의미</th>
					<th>실어 나르는 것</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>*.local.test</code></td>
					<td>이 Mac</td>
					<td>없음, dev 서버입니다</td>
			</tr>
			<tr>
					<td><code>*.ssh.test</code></td>
					<td>서버</td>
					<td>autossh 포워드</td>
			</tr>
			<tr>
					<td><code>*.fly.test</code></td>
					<td>Fly.io</td>
					<td>WireGuard 위의 <code>fly proxy</code></td>
			</tr>
	</tbody>
</table>
<p>그래서 <code>https://devbox-ollama.ssh.test/api/tags</code>는 워크스테이션의 모델 목록을 보여 주고, <code>https://api.local.test</code>는 제 기계에서 도는, 지금 작업 중인 앱의 API입니다.</p>
<p><code>.ssh</code>나 <code>.fly</code> 같은 새 최상위 이름이 아니라 <code>.test</code>의 서브도메인이고, <code>.local</code>도 아닙니다. RFC 6761이 바로 이런 용도로 <code>.test</code>를 예약해 두었고, <code>.local</code>은 mDNS 몫입니다. 그걸 가로채면 Mac에서 Bonjour 탐색이 멈춥니다. dnsmasq가 이미 <code>*.test</code> 전체를 와일드카드로 잡고 있으니 라벨 하나 더 붙이는 데 드는 비용은 없습니다. <code>127.0.0.1</code>을 가리키는 <code>/etc/resolver/test</code> 파일 하나면 되고, 그 뒤로 DNS를 더 건드릴 일은 영영 없습니다.</p>

<h3 class="relative group">존재하지 않는 이름에 HTTPS 붙이기
    <div id="존재하지-않는-이름에-https-붙이기" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%a1%b4%ec%9e%ac%ed%95%98%ec%a7%80-%ec%95%8a%eb%8a%94-%ec%9d%b4%eb%a6%84%ec%97%90-https-%eb%b6%99%ec%9d%b4%ea%b8%b0" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>저 이름들은 전부 브라우저가 신뢰하는 인증서로 HTTPS 서비스됩니다. 제 노트북에만 존재하는 호스트명에 그게 된다니 불가능하게 들리죠. Caddy는 자체 내부 인증 기관으로 이걸 해냅니다. 사이트에 <code>tls internal</code>을 넣으면 Caddy는 Let&rsquo;s Encrypt에 요청하는 대신 로컬 CA에서 그 사이트의 인증서를 발급하고, 설치 스크립트가 한 번 실행하는 <code>caddy trust</code>가 그 CA의 루트를 macOS 시스템 키체인(Chrome과 Safari가 읽는 곳)과 Firefox 자체 저장소에 넣습니다. 그다음부터 <code>https://anything.test</code>는 인증서 경고 없이 열리고, 평범한 <code>http://</code>는 그쪽으로 리다이렉트되며, 아무것도 기계 밖으로 나가지 않습니다.</p>
<p>겉모양만의 문제가 아닙니다. 요즘 웹 플랫폼의 상당 부분이 평범한 HTTP에서는 동작을 거부합니다. secure 쿠키, 서비스 워커, 클립보드 API, <code>SameSite=None</code>이 들어간 모든 것이요. Expo 웹 빌드를 <code>http://localhost:8081</code>에서 테스트하면 실제 HTTPS 도메인에서 나타날 버그가 가려집니다. <code>https://app.local.test</code>에서 테스트하면 그 버그가 제 노트북에서 대신 드러납니다.</p>
<p>사이트 블록 하나입니다. 포트만 빼면 모든 서비스가 똑같습니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">devbox-ollama.ssh.test</span>, <span class="gh">devbox-ollama.test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="k">tls</span> <span class="no">internal</span>
</span></span><span class="line"><span class="cl">	<span class="k">import</span> cors
</span></span><span class="line"><span class="cl">	<span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">11435</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">		<span class="k">import</span> proxyhdr
</span></span><span class="line"><span class="cl">		<span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="k">keepalive</span> <span class="no">off</span>
</span></span><span class="line"><span class="cl">			<span class="k">dial_timeout</span> <span class="mi">5s</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>손이 많이 가는 부분은 두 스니펫이 맡습니다. <code>cors</code>는 <code>*</code>를 보내는 대신 호출한 쪽의 <code>Origin</code>을 그대로 돌려줍니다. 자격 증명이 끼는 순간 <code>*</code>는 허용되지 않기 때문입니다. 그리고 <code>OPTIONS</code> 프리플라이트에는 직접 204로 답해서 업스트림이 신경 쓸 필요가 없게 합니다. <code>proxyhdr</code>는 <code>Host</code> 헤더를 다이얼 주소로 바꿔 씁니다. 라우터 관리 페이지나 Chrome의 원격 디버깅 엔드포인트 같은 까다로운 백엔드는 <code>Host</code>를 검사하는데, 이게 없으면 <code>devbox-ollama.ssh.test</code>를 처음 듣는 이름이라며 거부합니다. Ollama의 자체 origin 검사는 포트와 상관없이 루프백을 받아 주니, <code>Host</code>를 <code>127.0.0.1:11435</code>로 바꿔 쓰면 통과합니다.</p>
<p><code>keepalive off</code>는 제가 틀렸을 법한 부분입니다. 업스트림은 네트워크가 바뀔 때마다 autossh가 허물고 다시 세우는 SSH 포워드입니다. keep-alive가 켜져 있으면 Caddy는 이미 죽은 터널에 대한 풀링된 연결을 쥐고 있다가, 리로드할 때까지 영문 모를 404를 돌려줍니다. 끄면 요청마다 새로 다이얼하는데, 루프백에서는 측정할 만한 비용이 없습니다.</p>
<p>각 서비스는 예전의 평평한 이름(<code>devbox-ollama.test</code>)으로도 답하기 때문에, 접미사 체계 이전의 북마크도 여전히 동작합니다.</p>

<h3 class="relative group">메뉴 막대
    <div id="메뉴-막대" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%a9%94%eb%89%b4-%eb%a7%89%eb%8c%80" aria-label="앵커">#</a>
    </span>
    
</h3>
<p>launchd가 포워드 십여 개를 열어 두고 있으니 제가 계속 던진 질문은 &ldquo;살아 있나?&ldquo;였고, 그 답은 세 군데에 흩어져 있었습니다. <code>launchctl list</code>, <code>lsof</code>, 그리고 <code>curl</code>. 그래서 <a href="https://github.com/swiftbar/SwiftBar"  target="_blank" rel="noreferrer">SwiftBar</a> 플러그인으로 한곳에 모았습니다. 메뉴 막대에는 🚇가 뜨고, 그 아래 메뉴에 모든 호스트명이 점과 함께 나열됩니다.</p>
<ul>
<li>🟢 서비스가 터널을 통한 요청에 답했습니다</li>
<li>🟡 터널은 열려 있지만 그 뒤의 서비스가 조용합니다</li>
<li>🔴 로컬 포트에서 아무것도 듣고 있지 않으니, 터널 자체가 죽었습니다</li>
</ul>
<p>쓸모 있는 건 노란색입니다. 초록/빨강 검사로는 &ldquo;SSH 포워드가 죽었다&quot;와 &ldquo;반대편에서 Ollama가 뻗었다&quot;를 구분할 수 없고, 둘은 고치는 방법이 전혀 다릅니다. 서비스마다 Open, Kick tunnel, Restart Caddy, Tail log가 든 하위 메뉴가 있습니다. 이름을 클릭하면 브라우저에서 열리고, 동작들은 그 아래 별도의 회색 <code>port → remote · tunnel</code> 줄에 붙어 있습니다. SwiftBar에서는 자식이 있는 메뉴 항목이 자기 클릭을 펼치는 데 써 버리기 때문에, 둘을 합친 줄은 링크처럼 보이면서 절대 열리지 않거든요.</p>
<p>플러그인에는 자체 서비스 목록이 없습니다. 실행할 때마다 살아 있는 <code>Caddyfile</code>과 <code>~/.ssh/config</code>를 파싱하니, 그 두 파일에 서비스를 추가하는 것이 곧 메뉴에 추가하는 것입니다.</p>
<p>첫 버전은 10초마다 새로 고쳤고 <code>lsof</code>와 <code>launchctl</code>을 <em>서비스마다</em> 한 번씩 셸로 불러서, 한 번 돌 때 프로세스가 70개쯤 떴습니다. macOS는 곧바로 SwiftBar를 &ldquo;에너지를 많이 사용하는 앱&quot;에 올렸죠. 지금은 처음에 <code>lsof</code> 스냅샷 하나와 <code>launchctl</code> 스냅샷 하나를 떠 두고, 모든 조회를 그 문자열에서 답하고, 원격은 동시에 찔러 보고, 5분마다 돕니다. 한 번에 CPU 약 0.4초, 코어 하나의 0.1% 정도입니다. 메뉴를 열 때 새로 고치지 않는 건 의도한 겁니다. Fly 포워드는 WireGuard 너머로 답하는 데 각각 1.5초쯤 걸려서, 클릭할 때마다 그걸 기다리며 멈출 테니까요. 개수 아래의 <code>checked 14:05</code> 줄이 이 그림이 얼마나 묵었는지 알려 주고, Refresh 항목으로 새로 뜨게 할 수 있습니다.</p>
<p>구멍이 하나 있었는데, 하필 중요한 구멍이었습니다. devbox에는 공개 이름이 없습니다. <code>tunnel-devbox</code>는 <code>192.168.x</code> 주소로 다이얼했으니, 노트북이 사무실 LAN에 있을 때만 동작했습니다. 사무실을 벗어나면 메뉴 막대가 빨개진 채로 그대로였고, 노트북을 아끼려고 워크스테이션으로 옮겨 둔 모든 것이 정확히 제가 그 옆에 앉아 있지 않을 때 닿지 않았습니다.</p>

<h2 class="relative group">방향을 제대로 잡은 터널 두 개
    <div id="방향을-제대로-잡은-터널-두-개" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b0%a9%ed%96%a5%ec%9d%84-%ec%a0%9c%eb%8c%80%eb%a1%9c-%ec%9e%a1%ec%9d%80-%ed%84%b0%eb%84%90-%eb%91%90-%ea%b0%9c" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>해결책은 세 부분이고, 앞의 둘이 Cloudflare Tunnel입니다.</p>
<p>Cloudflare Tunnel은 <code>cloudflared</code>가 Cloudflare 엣지로 아웃바운드 연결을 붙잡고 있는 것입니다. Cloudflare는 그 연결로 요청을 내려보내니, NAT가 몇 겹이든 그 뒤의 기계가 라우터에서 아무것도 듣지 않고도 닿을 수 있게 됩니다. 노트북을 따라 호텔 wifi로 가고, CGNAT에서도 살아남습니다.</p>
<p>저는 기계마다 하나씩 두 개를 돌리는데, 그 이유는 한 문장 들일 만합니다. 뻔한 지름길은 노트북에 터널 하나를 두고, 사무실 LAN에 있는 devbox의 SSH 포트를 가리키는 ingress 규칙을 하나 더 넣는 것이었습니다. 그러면 devbox에는 아무것도 설치할 필요가 없습니다. 대신 노트북을 거쳐 라우팅되는데, 노트북은 사무실을 떠나는 기계입니다. 고치려던 바로 그 상황에서 고장 났을 겁니다. devbox는 제자리에 있으니, devbox가 엣지로 가는 자기 연결을 쥡니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># devbox/config.yml (run on devbox as a systemd service)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">auto       </span><span class="w"> </span><span class="c"># prefer QUIC, fall back to HTTP/2 over TCP 443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ha-connections</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">     </span><span class="c"># mains-powered, its job is to stay reachable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">warp-routing</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">connectTimeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ssh-devbox.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">tcp://127.0.0.1:22</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p><code>ssh://</code>가 아니라 <code>tcp://</code>라는 건 헷갈리기 쉬운 부분입니다. <code>ssh://</code>는 Cloudflare의 브라우저 렌더링 터미널을 선택하는데, 이건 스트림을 웹 페이지용으로 감쌉니다. 평범한 <code>ssh</code> 클라이언트를 거기로 통과시키면 배너까지는 받고 키 교환에서 죽습니다. <code>tcp://</code>는 <code>cloudflared access ssh</code>가 기대하는 평범한 바이트 파이프이고, 덤으로 <code>scp</code>, <code>rsync</code>, SSH 위의 git, 포트 포워드까지 실어 나릅니다.</p>
<p>그 마지막 부분이 Automatic Tunnel의 구멍을 막았습니다. 이제 SSH 설정이 연결마다 경로를 고릅니다.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-sshconfig" data-lang="sshconfig">Match originalhost devbox,tunnel-devbox !exec &#34;nc -z -G 1 &lt;office-lan-ip&gt; 22 &gt;/dev/null 2&gt;&amp;1&#34;
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...</code></pre></div>
<p>사무실 LAN에서는 탐침이 성공하고 SSH가 한 홉으로 직접 갑니다. 그 밖의 어디서든 탐침이 실패하고 SSH는 Cloudflare를 거치는데, Access 서비스 토큰으로 인증하니 브라우저 로그인이 끼어들지 않습니다. <code>Match host</code>가 아니라 <code>Match originalhost</code>입니다. <code>host</code>는 <code>HostName</code>이 치환된 뒤에 매칭되니, 그 시점엔 이름이 이미 IP라서 블록이 절대 발동하지 않습니다. 결과적으로 <code>devbox-ollama.ssh.test</code>가 사무실에서도, 집에서도, 기차에서도 동작하고, 메뉴 막대의 점은 초록으로 남습니다.</p>
<p>노트북의 터널은 반대 방향으로 갑니다. Mac의 dev 서버에 진짜 공개 HTTPS 호스트명을 줍니다. 작업 중인 앱마다 Django API, 그 WebSocket 앱, Expo 웹 빌드요. 전부 Cloudflare Access 뒤에 있는데, 이건 뒤에서 다시 다루겠습니다. 이걸 무모한 게 아니라 안전한 것으로 만드는 부분이니까요.</p>
<p>메뉴 막대도 두 터널을 알게 됐는데, 탐침으로는 할 수 없었습니다. 인바운드 터널에는 확인할 로컬 포트가 없고, 커넥터 둘 중 하나는 노트북에서 돌지도 않으며, 뻔한 테스트인 <code>ssh-devbox.example.com</code>에 curl 날리기는 커넥터가 붙어 있든 말든 Cloudflare 엣지가 답하니, 죽은 터널에서도 초록으로 읽힐 겁니다. launchd도 나을 게 없습니다. <code>cloudflared</code>는 엣지 연결이 전부 끊긴 채로 &ldquo;loaded, exit 0&rdquo; 상태로 앉아 있거든요. 그래서 메뉴의 그 부분은 Cloudflare API에 각 터널을 어떻게 보고 있는지 묻습니다. <code>ssh</code>가 닿을지를 결정하는 게 바로 그쪽이니까요. 각 줄에는 그 터널을 가진 기계에서 SSH로 실행되는 Tail log와 Restart connector 동작이 붙습니다. 4초 안에 API에 닿지 못하면 빨갛게 그리지 않고 그 부분을 통째로 뺍니다. 메뉴는 힐끗 보고 읽는 것이라, 거기서 잘못된 경보는 빠진 한 줄보다 비쌉니다.</p>

<h2 class="relative group">세 번째 부분: WARP가 이걸 네트워크로 만듭니다
    <div id="세-번째-부분-warp가-이걸-네트워크로-만듭니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%84%b8-%eb%b2%88%ec%a7%b8-%eb%b6%80%eb%b6%84-warp%ea%b0%80-%ec%9d%b4%ea%b1%b8-%eb%84%a4%ed%8a%b8%ec%9b%8c%ed%81%ac%eb%a1%9c-%eb%a7%8c%eb%93%ad%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>호스트명이 붙은 터널은 HTTPS로 말하는 것, 그리고 <code>cloudflared</code>를 돌릴 수 있는 기계에서의 SSH에는 훌륭합니다. 폰은 못 돌립니다. 게다가 터널 호스트명은 TCP 엔드포인트가 아닙니다. Cloudflare 엣지는 거기서 HTTPS와 WebSocket만 종단하니, <code>ssh.example.com</code>의 22번 포트는 닫혀 있고 Access를 아무리 만지작거려도 열리지 않습니다.</p>
<p>그게 WARP의 일입니다. Cloudflare One Agent(엔터프라이즈용 WARP 클라이언트입니다. 조직에 가입할 수 없는 일반 소비자용 &ldquo;1.1.1.1&rdquo; 앱이 아니고요)를 노트북과 폰에 설치해 제 Zero Trust 조직에 등록했습니다. 각 터널이 사설 경로를 광고하면, WARP를 돌리는 기기는 그 주소로 평범한 소켓을 열어 올바른 기계에 닿을 수 있습니다. &ldquo;이 서비스에 이름으로 닿기&quot;가 아니라 &ldquo;이 기계들이 나와 같은 네트워크에 있다&quot;가 되는 거죠.</p>
<table>
	<thead>
			<tr>
					<th>주소</th>
					<th>기계</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>198.18.0.1</code> / <code>fd00:198:18::1</code></td>
					<td>MacBook Air</td>
			</tr>
			<tr>
					<td><code>198.18.0.2</code> / <code>fd00:198:18::2</code></td>
					<td>devbox</td>
			</tr>
			<tr>
					<td><code>198.18.0.3</code> / <code>fd00:198:18::3</code></td>
					<td>두 번째 Mac</td>
			</tr>
	</tbody>
</table>
<p>이 주소들은 부팅 때 만들어지는 각 기계의 루프백 별칭이라, DHCP나 제가 어떤 라우터 뒤에 있는지와 아무 상관이 없습니다. <code>198.18.0.2</code>는 사무실에서도, 집에서도, 5G에 붙은 폰에서도 devbox입니다. Mac의 진짜 LAN 주소는 옮길 때마다 바뀌지만, 이 주소는 절대 바뀌지 않습니다.</p>
<p>스플릿 터널은 <strong>include 모드</strong>이고, 저 경로들(과 WARP가 스스로 쓰는 Cloudflare 대역)만 들어 있습니다. 이게 클라이언트를 켜 두고 살 수 있게 만드는 부분입니다. 기본값은 exclude 모드인데, WARP가 사설 대역 목록을 뺀 모든 것을 실어 나르니 폰이 보내는 모든 바이트가 Cloudflare를 돌아갑니다. 배터리와 지연 시간 비용이 실제로 있고, 결국 필요할 때만 켜게 됐을 만큼입니다. 그러면 요점이 무너지죠. include 모드에서는 제 기계 세 대로 가는 트래픽만 WARP를 거치고 나머지는 전부 평소 경로로 손대지 않은 채 나갑니다. 그래서 클라이언트는 그냥 켜진 채로 있습니다. 노트북의 <code>warp-cli status</code>는 하루 종일 <code>Connected</code>라고 하고, 저는 신경 쓰지 않습니다.</p>
<p>전송 방식은 UDP 2408 위의 WireGuard가 아니라, UDP 443 위의 HTTP/3에 HTTP/2 폴백이 붙은 MASQUE로 해 두었습니다. 일부 통신사와 게스트 네트워크는 2408을 막지만, 443을 막는 곳은 거의 없습니다. 제 조직 정책에서는 MASQUE에 양자내성 키 합의도 켜져 있는데, 찾아서 켠 건 아니지만 마다할 이유도 없죠.</p>

<h2 class="relative group">왜 198.18이고, 왜 IPv6 주소가 있는지
    <div id="왜-19818이고-왜-ipv6-주소가-있는지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%99%9c-19818%ec%9d%b4%ea%b3%a0-%ec%99%9c-ipv6-%ec%a3%bc%ec%86%8c%ea%b0%80-%ec%9e%88%eb%8a%94%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>주소가 좀 이상해 보이는데, 처음 고른 주소는 배울 게 있는 방식으로 틀렸습니다.</p>
<p>첫 시도는 <code>10.99.99.1</code>이었습니다. 다들 손이 가는 주소니까요. 이건 두 스플릿 터널 모드 모두에서 실패합니다. exclude 모드에서는 WARP의 기본 목록이 RFC 1918 대역 전부와 <code>100.64.0.0/10</code>을 제외하니, 클라이언트가 그 주소로 가는 트래픽을 기기를 떠나기도 전에 버렸을 겁니다. include 모드에서는 제가 어쩌다 붙는 실제 <code>10.x</code> 네트워크와 충돌했을 겁니다. 카페, 코워킹 스페이스, 그리고 꽤 많은 사무실이 딱 그 대역을 씁니다. <code>198.18.0.0/15</code>는 RFC 2544 벤치마킹 대역입니다. 예약돼 있고, 공용 인터넷에서 라우팅되는 일이 없고, WARP의 exclude 목록에 없으며, 평범한 네트워크라면 쓰지 않습니다. IPv6 주소가 ULA(<code>fd00::/8</code>)인 것도 같은 이유입니다.</p>
<p>IPv6가 애초에 왜 있는지가 더 재미있는 이야기입니다. v4 주소가 동작하자 폰에서 wifi로 테스트해 봤고, 잘 됐고, 끝났다고 선언했습니다. 5G에서는 안 됐습니다. Agent는 <em>connected</em>라고 했고, 터널은 멀쩡했고, Access도 괜찮았습니다. wifi 테스트가 그 전부를 증명했으니까요. 문제는 요즘 모바일 네트워크가 대부분 IPv6 전용이라는 점이었습니다. 폰에는 네이티브 IPv4가 아예 없었고, v4 리터럴에는 통신사의 464XLAT 변환기를 통해 닿고 있었는데, include 목록에 v4 접두사만 있으면 그 변환기는 터널 <em>바깥</em>에 놓입니다. 트래픽은 <code>198.18.0.0/15</code>가 라우팅되지 않는 공용 인터넷으로 나갔고, 소리 없이 버려졌습니다. 폰에도 오류가 없고, 커넥터 로그에도 아무것도 없었습니다.</p>
<p>해결책은 모든 기계에 폰이 IPv6로 네이티브하게 닿을 수 있는 두 번째 주소를 주고, 그걸 경로와 include 목록에 추가하는 것입니다. 이제 모바일 데이터에서 폰의 SSH 클라이언트가 실제로 쓰는 건 <code>fd00:198:18::</code> 주소입니다.</p>

<h2 class="relative group">오프로딩: 단말기로서의 노트북
    <div id="오프로딩-단말기로서의-노트북" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%98%a4%ed%94%84%eb%a1%9c%eb%94%a9-%eb%8b%a8%eb%a7%90%ea%b8%b0%eb%a1%9c%ec%84%9c%ec%9d%98-%eb%85%b8%ed%8a%b8%eb%b6%81" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>이 셋업 전체가 존재하는 이유가 이 부분입니다.</p>
<p><strong>모델과 파인튜닝.</strong> Ollama는 devbox에서 돌고 <code>127.0.0.1:11434</code>에 바인딩됩니다. 인터넷은 고사하고 사무실 LAN의 다른 기계에서도 보이지 않습니다. 노트북에서는 <code>https://devbox-ollama.ssh.test</code>로 나타나고, 포트 번호만 원하는 도구에는 그냥 포트로 나타납니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">OLLAMA_HOST</span><span class="o">=</span>127.0.0.1:11435 ollama list</span></span></code></pre></div></div>
<p>11434가 아니라 11435인 건 일부러입니다. 11434는 Ollama 자체 기본값이라, 노트북에서도 로컬 Ollama를 돌리게 되면 둘이 포트를 두고 경쟁하고, 어느 쪽이 먼저 떴느냐에 따라 &ldquo;로컬&rdquo; 쪽에서 devbox의 답을 소리 없이 받게 됩니다.</p>
<p>실제 효과는, AI 기능을 만들 때 그 기능이 호출하는 모델이 제가 어디에 있든 노트북이 아니라 워크스테이션 GPU라는 점입니다. 평가 실행, 문서 한 무더기 임베딩, Unsloth Studio에서의 파인튜닝은 devbox에서 돌고, 노트북 팬은 조용합니다. 에이전트를 프로토타이핑할 때는 워크스테이션의 로컬 모델을 가리키게 해서 API 청구서 없이 툴 호출 반복을 마음껏 태울 수 있고, 루프가 제대로 잡히면 호스팅 모델로 바꾸면 됩니다.</p>
<p><strong>오래 도는 에이전트.</strong> 한 시간 도는 에이전트 세션이 제 노트북 덮개가 열려 있는지에 달려 있어서는 안 됩니다. 대신 devbox에서 돌리면 노트북은 그저 거기로 난 창문입니다. 덮개를 닫고, 나중에 어디서든, 혹은 폰에서 <code>ssh devbox</code>를 하면 여전히 거기 있습니다. 터널 이전에는 이게 사무실에서만 됐는데, 사무실은 정확히 이게 필요 없는 곳이었죠.</p>
<p><strong>빌드와 클러스터.</strong> devbox의 k3s에는 자체 레지스트리가 있어서, 이미지를 노트북에서 호텔 wifi로 올리는 대신 워크스테이션에서, 실행될 곳 바로 옆에서 빌드하고 푸시할 수 있습니다. Headlamp 대시보드, Traefik 대시보드, 레지스트리 UI는 노트북에서 전부 <code>*.ssh.test</code> 호스트명입니다.</p>
<p><strong>모바일 루프.</strong> 폰은 노트북과 같은 사설 네트워크에 있고, 노트북의 dev 서버에는 Access 뒤의 공개 호스트명도 있습니다. 그래서 네이티브 Expo 빌드를 들고 건물 밖으로 나가 5G에서 써도, 제 책상에서 도는 Django API와 통신합니다. 제가 devbox에 띄워 두었다면 그쪽과 통신하고요. 실제 모바일 네트워크 위의 실제 기기로 피드백 루프를 돌리려면 예전에는 공유 스테이징 서버에 배포해야 했습니다. 지금은 파일을 저장하면 됩니다.</p>
<p>앱 버그로 착각하고 디버깅하기 전에 알아 둘 만한 함정이 하나 있습니다. Access는 WebSocket 호스트명도 보호하는데, 네이티브 WebSocket 클라이언트는 브라우저가 아닙니다. <code>CF_Authorization</code> 쿠키를 들고 있지 않으니, Expo 빌드가 Channels 앱에 연결하면 엣지에서 403으로 거부되는데, 이게 Channels 오류와는 전혀 닮지 않았습니다. 브라우저 탭은 로그인할 때 받은 쿠키를 이미 들고 있으니 괜찮습니다. 브라우저가 아닌 모든 것(네이티브 앱, 스크립트, dev API를 호출하는 에이전트)에 대한 답은 Access <strong>서비스 토큰</strong>이고, 헤더 두 개로 보냅니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">CF-Access-Client-Id:     &lt;client_id&gt;
</span></span><span class="line"><span class="cl">CF-Access-Client-Secret: &lt;client_secret&gt;</span></span></code></pre></div></div>
<p>토큰은 그 자체로는 아무 권한도 주지 않습니다. Access는 정책에 그 토큰이 명시된 앱에서만 받아 주니, 토큰이 닿는 범위는 정확히 dev 호스트명 목록이고 그보다 넓지 않습니다. 저는 그 정책을 <code>allow</code>가 아니라 <code>non_identity</code> 결정으로 분류해 두는데, 그러면 감사 로그에서 로그인한 사람이 아니라 bearer 시크릿으로 보입니다.</p>

<h2 class="relative group">인터넷은 절대 보지 못하는 통계
    <div id="인터넷은-절대-보지-못하는-통계" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%ec%9d%b8%ed%84%b0%eb%84%b7%ec%9d%80-%ec%a0%88%eb%8c%80-%eb%b3%b4%ec%a7%80-%eb%aa%bb%ed%95%98%eb%8a%94-%ed%86%b5%ea%b3%84" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>제가 원한 것의 나머지 절반은 가시성이었습니다. 시스템이 어떻게 돌아가는지 알려 주는 것 대부분은 공개돼 있지 않은 게 맞습니다. devbox에서는 이런 것들입니다.</p>
<ul>
<li>Traefik 대시보드, 그리고 Traefik 트레이스가 담긴 Jaeger</li>
<li>k3s 클러스터용 Headlamp</li>
<li>컨테이너 레지스트리 UI(읽기 전용)</li>
<li>Ollama 모델 목록과 지금 로드된 것</li>
<li><code>docker info</code>와 컨테이너 통계</li>
</ul>
<p>이 중 어느 것도 공개 호스트명이 없고, 앞으로도 없을 겁니다. 루프백이나 k3s 서비스 IP에 바인딩돼 있고, 그 자체가 터널을 타는 SSH 포워드로 닿습니다. 폰의 모니터링 앱은 WARP 너머 SSH로 <code>docker info --format '{{json .}}'</code>를 읽으니, 컨테이너가 떠 있는지 아니면 뭔가 메모리를 다 먹어 치웠는지 어디서든 볼 수 있습니다.</p>
<p>그 앱 덕분에 언급할 만한 작은 버그를 하나 찾았습니다. Mac의 터널용 sshd가 디버깅하던 시절 그대로 <code>LogLevel DEBUG2</code>였습니다. <code>-e</code>를 주면 sshd는 stderr로 로그를 남기는데, 세션에서는 그 stderr가 <em>곧</em> SSH 채널의 stderr입니다. 모든 명령의 출력 끝에 <code>debug2: do_setup_env: set TMPDIR</code>가 붙어서 돌아왔고, 두 스트림을 합치는 폰 앱은 유효한 JSON 뒤에 로그 한 줄이 붙은 걸 받아 파싱에 실패하고는 통째로 오류로 보여 줬습니다. <code>LogLevel INFO</code>로 고쳤습니다. 디버깅이 끝나면 로그 레벨을 되돌려 놓으세요.</p>
<p>이 네트워크에 <em>올리지 않는</em> 것도 똑같이 의도적입니다. 예전 autossh 터널은 제가 관리를 돕는 남의 네트워크의 라우터 몇 대에도 닿습니다. 그것들에는 공개 호스트명도, WARP 경로도 주지 않습니다. 인터넷에 나온 라우터 관리 페이지는 로그인 뒤에 있어도 골칫거리고, 제가 넓힐 수 있는 제 네트워크도 아니니까요.</p>

<h2 class="relative group">Access는 선택이 아닙니다
    <div id="access는-선택이-아닙니다" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#access%eb%8a%94-%ec%84%a0%ed%83%9d%ec%9d%b4-%ec%95%84%eb%8b%99%eb%8b%88%eb%8b%a4" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>Cloudflare Tunnel 호스트명은 DNS 레코드가 생기는 순간 공용 인터넷에 올라갑니다. 앞에 방화벽이 없습니다. 포트를 열지 않는다는 게 애초에 요점이니까요. 보호는 Cloudflare 엣지에서 와야 하고, 그게 Access가 하는 일입니다. 방문자가 누구인지 증명하기 전까지는 요청을 아예 전달하지 않으니, 인증되지 않은 스캐너는 노트북에 절대 닿지 못합니다. 이게 중요한 건 이것들이 dev 서버이기 때문입니다. 설정값이 그대로 드러나는 Django <code>DEBUG</code> 트레이스백, 열려 있는 Metro 번들러. 이 호스트명들 뒤에 있는 것 중 인터넷을 마주하도록 만들어진 건 하나도 없습니다.</p>
<p>정책은 제 이메일 주소들만 허용하고 나머지는 아무것도 허용하지 않으며, 증명 방법은 이메일 일회용 PIN과 GitHub SSO 두 가지입니다. 세션은 8시간인데, 현실적인 위협은 누군가 PIN을 무차별 대입하는 게 아니라 하루짜리 쿠키를 품은 채 카페에 열어 두고 온 노트북이기 때문입니다.</p>
<p>SSH는 서로의 존재를 모르는 두 시스템이 두 번 인증합니다. Access는 TCP 스트림이 sshd에 닿아도 되는지를 정하고, sshd는 그래도 제 키를 요구합니다. 어느 하나를 잃어도 기계가 열리지는 않습니다. Mac의 터널용 sshd는 Cloudflare의 SSH CA도 신뢰해서, Access가 제 SSO 세션으로 발급한 단기 인증서로 저를 로그인시킬 수 있습니다. 그러면서 <code>authorized_keys</code>도 나란히 동작하니, Access 설정이 망가져도 제 노트북에서 제가 쫓겨나는 일은 없습니다.</p>
<p>제일 마음에 드는 건 작은 부분입니다. 노출은 세 가지가 맞아떨어질 때 일어납니다. DNS 레코드, 돌고 있는 커넥터, 활성 상태의 존. 이 셋은 제가 통제하지 못하는 순서로, 서로 다른 시점에 참이 됩니다. 존은 네임서버를 바꾸고 얼마 뒤에 혼자 활성으로 바뀌는데, Mac이 잠들어 있을 때일 수도 있습니다. 그래서 launch agent는 <code>cloudflared</code>를 직접 실행하지 않습니다. 대신 터널 자체 설정에서 호스트명을 읽어 Access API에 어느 것이 보호되고 있는지 묻고, 하나라도 빠져 있으면 시작을 거부하는 사전 점검을 돌립니다.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">HOSTNAMES</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^\s*-\s*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*hostname:\s*//&#39;</span> <span class="p">|</span> sort -u<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROTECTED</span><span class="o">=</span><span class="k">$(</span>curl -fsS -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CLOUDFLARE_API_TOKEN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;https://api.cloudflare.com/client/v4/accounts/</span><span class="nv">$CLOUDFLARE_ACCOUNT_ID</span><span class="s2">/access/apps?per_page=100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> python3 -c <span class="s1">&#39;import sys,json; print(&#34;\n&#34;.join(a.get(&#34;domain&#34;,&#34;&#34;) for a in json.load(sys.stdin)[&#34;result&#34;]))&#39;</span><span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;could not reach the Access API. refusing (fail closed).&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="nv">$HOSTNAMES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    grep -qxF <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$PROTECTED</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;NOT starting: </span><span class="nv">$h</span><span class="s2"> has no Access app&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> cloudflared tunnel --config <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> run</span></span></code></pre></div></div>
<p>launch agent에는 <code>KeepAlive</code>가 걸려 있어서, 거부되면 30초마다 알아서 재시도하고 Access 앱이 생기면 터널이 혼자 올라옵니다. 닫힌 채로 실패하면 장애를 치르고, 열린 채로 실패하면 노출을 치릅니다. 둘 중 어느 쪽을 해명하고 싶은지는 압니다.</p>

<h2 class="relative group">발목을 잡은 네 가지
    <div id="발목을-잡은-네-가지" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%b0%9c%eb%aa%a9%ec%9d%84-%ec%9e%a1%ec%9d%80-%eb%84%a4-%ea%b0%80%ec%a7%80" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>위의 모든 게 지금은 동작합니다. 여기까지 오는 동안 넷 중 셋은 패턴이 같았습니다. 고장 난 부분은 어디에도 오류를 내지 않았고, 제 눈에 보이는 부분은 전부 정상이라고 보고했습니다.</p>
<p><strong>경로와 include 목록은 서로 다른 목록입니다.</strong> 터널 경로는 Cloudflare가 어떤 주소를 올바른 커넥터로 실어 나를 <em>의향</em>을 갖게 합니다. WARP include 목록은 클라이언트가 애초에 그걸 <em>보내게</em> 만드는 것입니다. devbox가 폰에서 닿지 않았는데, 경로는 있었지만 include 목록에는 여전히 Mac의 주소만 있었기 때문입니다. 폰은 패킷을 로컬에서 버렸고, 커넥터는 흐름을 보지 못했고, 모든 대시보드는 초록이었습니다. 새 기계를 들일 때마다 주소를 양쪽 모두에 추가해야 합니다.</p>
<p><strong>IPv6 전용 모바일 네트워크의 IPv4 리터럴.</strong> 위에서 다뤘습니다. wifi에서는 되고, 5G에서는 안 되고, 오류는 없습니다. WARP 경로가 wifi에서는 되는데 모바일 데이터에서 안 되고 Agent가 connected로 나온다면, 터널이 아니라 라우팅 문제입니다.</p>
<p><strong>cloudflared 뒤의 macOS sshd.</strong> macOS는 sshd를 launchd 소켓 활성화로 띄웁니다. 연결마다 수명이 짧은 <code>sshd -i</code>가 하나씩 뜨는 식이죠. cloudflared 뒤에서는 배너가 클라이언트에 닿은 다음, 클라이언트의 식별 문자열이 돌아오기 전에 스트림이 끊겨서, 모든 로그인이 <code>kex_exchange_identification</code>에서 죽습니다. 양쪽 끝에서 확인했고, 평범한 TCP 릴레이로도 재현해서 Cloudflare 탓이 아님을 확인했습니다. 해결책은 <code>127.0.0.1:2222</code>와 WARP 루프백 주소에서 제 사용자로 도는 두 번째 독립 <code>sshd -D</code>인데, 오직 터널의 origin 노릇을 하려고 존재합니다. 시스템 설정의 원격 로그인은 건드리지 않았습니다. 평범하게 오래 사는 sshd를 쓰는 Linux인 devbox에는 이 문제가 애초에 없었습니다.</p>
<p><strong>더는 존재하지 않는 설정 필드.</strong> 예전 예제에는 터널 설정에 <code>warp-routing: enabled: true</code>가 있습니다. cloudflared 2026.8은 그 필드를 대놓고 거부합니다. WARP 라우팅은 블록이 있고 경로가 터널을 가리키기만 하면 켜집니다. 이건 적어도 오류를 냈는데, 다른 셋을 겪은 뒤라 거의 너그럽게 느껴졌습니다.</p>

<h2 class="relative group">무엇이 바뀌었나
    <div id="무엇이-바뀌었나" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%eb%ac%b4%ec%97%87%ec%9d%b4-%eb%b0%94%eb%80%8c%ec%97%88%eb%82%98" aria-label="앵커">#</a>
    </span>
    
</h2>
<p>솔직하게 요약하면, 하드웨어가 어디 있느냐에 따라 일할 곳을 고르는 걸 그만뒀습니다. 노트북은 배터리가 오래가는 좋은 키보드와 좋은 화면이고, 그 이상일 필요가 없습니다. 워크스테이션은 제가 어디에 있든 무거운 일을 맡습니다. 폰은 사무실 wifi에 묶인 무언가가 아니라 실제 네트워크 위의 실제 테스트 기기입니다. 그리고 시스템이 뭘 하는지 보는 데 쓰는 부분들은 저에게는 클릭 한 번 거리에 있고, 다른 누구에게는 존재하지 않습니다.</p>
<p>이 규모면 Cloudflare의 무료 Zero Trust 플랜에 들어갑니다. 제가 치른 건 시간, 그리고 써 두길 잘했다 싶은 README 하나입니다. 위에 적은 실패는 하나같이 두 번째에도 새로운 미스터리였을 테니까요.</p>
<p>비슷한 걸 고민하고 있다면, 여러분의 셋업에 이런 질문을 던져 보겠습니다.</p>
<ol>
<li>노트북이 언제나 닿을 수 있는 유일한 기계라는 이유만으로 노트북에서 돌고 있는 건 무엇인가요?</li>
<li>폰이 사무실 wifi에서만이 아니라 모바일 데이터로도 dev 서버에 닿나요? wifi 말고 5G에서 테스트해 보세요.</li>
<li>밖에서 보기 쉬운 방법이 그것뿐이라서 공개된 대시보드는 어느 것인가요? 하나하나가 사설 경로로 옮길 후보입니다.</li>
<li>내일 터널 호스트명이 Access 앱 없이 올라온다면, 무엇이 그걸 막나요? 답이 &ldquo;제가 기억하겠죠&quot;라면 사전 점검을 작성하세요.</li>
<li>여러분의 사설 주소는 여러분이 드나드는 네트워크가 절대 쓰지 않을 대역에 있나요?</li>
</ol>
]]></content:encoded>
    </item>
  </channel>
</rss>
