나의 에이전트 환경 구축 3부작. 1부부터 시작
저는 업무용 노트북과 가정용 노트북, 두 대의 노트북을 가지고 있습니다. 두 대 모두 herdr가 실행되며 에이전트들이 작동하고 있습니다. 저는 이 중 한 대 앞에 앉아 있습니다. 다른 쪽 노트북에 있는 에이전트들은 계속 작업을 진행하며, 때로는 저를 기다리며 멈춥니다: 질문이나 승인, 또는 “다 끝났으니 다음은 무엇인가요?” 같은 것들입니다.
저는 세 가지 것을 원했습니다:
- 휴대폰으로 두 노트북의 에이전트를 보고 답변할 수 있게 하는 것.
- 업무용 노트북에서 가정용 노트북에 셸(shell)을 얻는 것.
- 업무용 노트북에는 업무 자격 증명(credentials)이 담겨 있어 외부 연결을 받지 않습니다. 휴대폰으로부터도, 가정용 노트북으로부터도, 어디로부터도 안 됩니다.
아이디어는 이렇습니다: 각 노트북이 자체적인 고정 IP 주소 뒤에 릴레이(relay)를 실행하고, 두 릴레이 모두가 휴대폰 앱의 동일한 곳을 가리키게 합니다. 따라서 하나의 앱으로 두 노트북의 에이전트를 볼 수 있으며, 어느 쪽 노트북도 인터넷에서 리스닝(listening)하지 않습니다.
릴레이는 쉬운 부분이었습니다. 도메인, 앱 주소, 그리고 덮개를 닫아두는 것이 어려웠습니다.
The phone app talks to two relays. Each laptop dials out to Cloudflare through its own tunnel, so neither one opens a port. The work laptop's relay also serves the phone app, and the home laptop's relay joins that same app.
제가 사용하는 것들
| 출발지 | 도착지 | 방법 | 시기 |
|---|
| 휴대폰 | 두 노트북 | Cloudflare 터널을 통한 herdr-mobile-relay | 매일 |
| ... | | | |
휴대폰 앱은 0cv가 만든 herdr 플러그인인 herdr-mobile-relay입니다. 자체 문서와 설정 마법사가 있기 때문에 여기에서 반복하지 않겠습니다. 이 게시물은 그것을 중심으로 한 제 환경 구축과, 무엇이 문제가 되었는지에 대한 내용입니다.
문제가 된 부분
이미 가지고 있던 도메인을 사용할 수 없었습니다
저는 각 노트북마다 영구적인 호스트 이름(hostname)을 원했기 때문에 릴레이의 안정적인 터널 옵션을 사용했습니다. 이것은 Cloudflare 터널에서 실행되며, Cloudflare 터널은 해당 도메인의 DNS가 Cloudflare에 호스팅되어야 합니다.
문제는 다음과 같다: Enterprise 플랜 미만에서는 Cloudflare가 단일 서브도메인만 가져갈 수 없다. 전체 존(zone)을 통째로 가져가야 한다. 기존에 사용하던 도메인은 DNS가 다른 곳에 있었고, 그곳에서 웹사이트와 이메일 포워딩을 처리하고 있었다. 단순히 릴레이를 위한 호스트 이름 하나를 얻기 위해 모든 것을 옮기는 것은 의미가 없었다.
그래서 Cloudflare Registrar에서 새 도메인을 구매했다. 이 도메인은 Cloudflare DNS로 시작하기 때문에, 이동할 것이 전혀 없고 마법사(wizard)가 자체적으로 릴레이 레코드들을 생성해준다. 이 도메인에는 다른 어떤 것도 존재하지 않는다. 본문에서는 이것이 example.dev이다.
마법사를 실행하기 전에 존이 활성화되었는지 확인해야 한다. 구매 후 최대 한 시간이 걸릴 수 있다:
dig NS example.dev +short # 두 개의 *.ns.cloudflare.com 이름
두 번째 노트북으로 두 번째 앱 만들기
마법사는 비슷해 보이지만 다른 두 가지 질문을 한다.
첫 번째는 호스트 이름(hostname)이다. 이것은 해당 노트북 자체의 주소이며, 각 노트북마다 고유한 것이 할당된다:
- 업무용 노트북:
work-relay.example.dev
- 가정용 노트북:
home-relay.example.dev
두 번째는 “폰 설정 링크를 어디서 열까요?”이다. 이것은 노트북에 관한 것이 아니다. 휴대폰 앱이 어느 곳에서 제공될지를 결정한다.
이는 iOS에서 중요하다. 왜냐하면 iOS는 홈 화면 앱을 설치된 주소와 연결하기 때문이다. 만약 각 노트북이 자체 앱을 제공한다면, 휴대폰에는 두 개의 별도 Herdr 앱이 생기고, 각각의 앱에 하나의 노트북만 포함된다. 이것은 내가 원했던 것과 정반대이다.
따라서 두 노트북은 다르게 응답한다:
| 업무용 노트북 | 가정용 노트북 |
|---|
| 노트북 자체 주소 | work-relay.example.dev | home-relay.example.dev |
| ... | | |
그 후, 휴대폰에서는 카메라를 이용하는 것이 아니라 설치된 앱 내부에서 가정용 노트북의 초대장을 가져온다. 그러면 앱에 2/2개의 릴레이가 표시되며, 두 노트북의 에이전트들이 하나의 목록에 포함된다.
휴대폰 앱은 하나의 노트북에 존재한다
그 답변에는 비용이 따릅니다. 업무용 노트북이 휴대폰 앱에 서비스를 제공하므로, 앱을 새로 설치하거나 업데이트하려면 업무용 노트북이 깨어 있고 온라인 상태여야 합니다. 리레이(relay) 설정 메뉴에는 앱 배포 구성 (Configure App Deployment) 옵션이 있는데, 이를 나중에 Cloudflare Pages로 옮겨 해당 의존성을 제거할 수 있습니다. 앱 업데이트를 게시하는 노트북 하나에서만 실행하세요.
하지 말아야 할 또 다른 것: 리레이 호스트 이름 앞에 Cloudflare Access를 두지 마세요. 리레이는 이미 페어링된 장치만 허용하며, Access가 휴대폰 앱을 차단합니다.
뚜껑을 닫아도 맥(Mac)은 여전히 절전 모드로 들어갑니다
잠든 노트북에는 접근할 수 없습니다. 리레이로도 안 되고, SSH를 통해서도 안 됩니다.
제가 집 노트북에 설정한 첫 번째 방법은 일반적인 방식이었습니다:
sudo pmset -c sleep 0 # 충전기 연결 시 유휴 절전(idle-sleep) 방지
이것은 단지 유휴 절전만 막을 뿐입니다. 뚜껑을 닫는 것은 별도의 트리거입니다. macOS가 뚜껑을 무시하는 경우는 클램쉘 모드(clamshell mode)일 때뿐이며, 클램쉘 모드는 세 가지가 필요합니다: 전원 어댑터(charger), 외부 디스플레이, 그리고 외부 키보드 또는 마우스. 디스플레이 없이는 sleep 0을 사용하더라도 전원 로그에는 다음과 같이 절전이 표시됩니다:
Entering Sleep state due to 'Clamshell Sleep' ... Using AC
제 집 노트북은 HDMI 포트가 있는 MacBook Pro였기 때문에 해결책은 약 10달러짜리 HDMI 더미 플러그였습니다. 이것이 macOS에 모니터가 있다고 알려줍니다. 이 플러그를 연결하고 충전기를 꽂으면, 맥은 뚜껑을 닫은 상태에서도 깨어 있게 유지됩니다. Apple 문서는 키보드나 마우스도 필요하다고 명시하지만, 이 Mac에서는 필요하지 않았습니다.
저는 밤새 테스트했습니다. 플러그만 연결하고 마우스 없이 뚜껑을 약 16.5시간 동안 닫아두었습니다. 가장 좋은 확인 방법은 방대하고 잡음이 많은 전원 로그가 아닙니다. 이것입니다:
sysctl kern.sleeptime kern.waketime
만약 두 시간 모두 뚜껑을 닫기 이전의 시간이라면, Mac은 절전하지 않은 것입니다. 제 것에는 여전히 플러그를 넣기 이전의 절전 기록이 남아 있었습니다.
도움이 되지 않은 두 가지:
sudo pmset -c disablesleep 1은 덮개를 닫아도 기기를 깨어 있게 유지하지만, 제 Mac에서는 충전기에만 제한되지 않았습니다. 전역 설정으로 표시되어 배터리로 작동하는 닫힌 Mac도 계속 깨어 있었습니다. 그래서 이 설정을 다시 비활성화했습니다.
- Herdr.app에는 상태 표시줄에 커피잔 아이콘이 있어 예를 들어 “에이전트가 완료될 때까지”와 같이 Mac을 깨어 있게 유지합니다. 이는 유휴 슬립(idle sleep)만 중지시킵니다. 덮개를 닫으면 여전히 Mac이 잠듭니다.
홈 노트북의 전체 AC 설정:
sudo pmset -c sleep 0 # 충전기 연결 시 유휴 슬립 방지
sudo pmset -c disksleep 0
sudo pmset -c womp 1 # 네트워크 접속 시 깨우기(wake on network access)
...
-c는 충전기에만 적용됩니다. 배터리는 기본 설정을 유지합니다.
한 가지 슬립 설정이 고치지 못한 경우: FileVault가 활성화된 상태에서 재부팅하면 Mac은 누군가가 직접 잠금을 해제할 때까지 로그인 화면에서 대기합니다.
iOS에서의 페어링 경고
이것은 설정 문서에 나온 내용이며, 잘못 이해하기 쉽습니다. iOS에서는 홈 화면 앱과 브라우저가 별도의 저장 공간을 유지합니다. 따라서 페어링 QR 코드를 스캔하여 브라우저 탭에서 페어링하면, 앱 자체가 아닌 해당 탭만 페어링된 것입니다.
대신 해야 할 일:
- 카메라로 QR 코드를 스캔합니다. 페이지에 “홈 화면에 Herdr 추가”라고 되어 있습니다. “이 브라우저 대신 페어링”을 누르지 마십시오.
- 홈 화면에 추가한 다음, 홈 화면 아이콘에서 열어봅니다.
- 앱에서 “장치를 거부했습니다(refused this device)”라는 메시지가 표시되면, 일회용 초대 코드가 이미 브라우저 탭에 의해 사용된 것입니다. 노트북에서 새로운 코드를 인쇄하고(아래 명령어), 앱 내부로 가져옵니다.
- 앱 내에서 **톱니바퀴 > 장치(gear > Devices)**를 열고 이 장치의 이름을 변경한 다음, 브라우저 탭이 남긴 추가 항목은 모두 취소합니다.
새로운 초대 코드:
herdr plugin action invoke setup-link --plugin herdr-mobile-relay.events
설정하기
노트북당 하나의 릴레이(One relay per laptop)
각 노트북에서 다음 명령을 실행합니다:
herdr --version # 0.7.5 이상
herdr plugin install 0cv/herdr-mobile-relay
herdr plugin action invoke install-service --plugin herdr-mobile-relay.events
마지막 명령어는 stable tunnel wizard입니다. 이 마법사는 Cloudflare 로그인용 브라우저를 열고, 위에서 언급된 호스트 이름과 앱 질문을 요청한 다음, 터널, DNS 레코드 및 백그라운드 서비스를 생성하고 페어링 QR 코드를 출력합니다.
로그인 과정은 ~/.cloudflared/cert.pem 파일을 작성합니다. 이 파일은 자격 증명(credential)입니다. 이는 절대 dotfiles에 저장되지 않습니다.
릴레이를 확인하려면:
herdr plugin action invoke status --plugin herdr-mobile-relay.events
curl -s http://127.0.0.1:8375/readyz -o /dev/null -w '%{http_code}\n' # 준비 완료 시 200 코드 출력
두 노트북 모두 Cloudflare로 연결됩니다. 어느 쪽에도 포트가 열리지 않으며 라우터도 건드리지 않습니다. 릴레이를 통한 트래픽은 종단 간 암호화(end-to-end encrypted)되어 있어, Cloudflare는 프롬프트나 터미널 출력이 아닌 타이밍과 프레임 크기만 볼 수 있습니다.
장치 및 역할 (Devices and roles)
제 휴대폰만이 유일한 **제어기(controller)**입니다. 제어기는 타이핑을 하고, 승인을 처리하며, 에이전트를 시작하고 중지할 수 있습니다. 다른 모든 장치는 **읽기 전용 장치(reader)**입니다. 읽기 전용 장치는 관찰만 할 수 있을 뿐, 타이핑하거나, 승인하거나, 업로드할 수는 없습니다.
앱 설정에서 장치 잠금(device lock) 기능이 활성화되어 있어, 앱을 열거나 돌아올 때마다 장치를 확인합니다.
페어링 QR 코드와 링크는 비밀번호 역할을 합니다. 각각은 1회 사용 가능한 초대권을 담고 있으며 10분 후에 만료됩니다. 스크린샷을 찍거나 다른 곳에 붙여넣지 마십시오.
휴대폰과 Firstmate
Firstmate가 동일한 herdr 세션을 구동합니다. 저는 휴대폰에서 팀원들을 관찰하며, 저 자신의 세션으로 작업합니다. 저는 Firstmate 워커의 창에 직접 타이핑하지 않습니다. Firstmate는 이를 제가 개입하는 것으로 간주하고 다음 확인 시 자동으로 수정합니다.
업무용 노트북에서의 SSH
휴대폰은 관찰과 답변을 위한 것입니다. 홈 노트북에서 셸(shell) 작업을 할 때는, 저는 업무용 노트북에서 SSH로 접속합니다. 이 설정을 두 가지 방식으로 구성했으며, 일상적으로 Tailscale을 사용합니다.
The work laptop reaches the home laptop's SSH in two ways: over Tailscale, where the policy allows only port 22 on the machine tagged tag:home, and over Cloudflare Access, where the home laptop dials out and the work laptop's ssh goes through cloudflared. Nothing reaches the work laptop: no SSH server, Shields Up on, and the policy denies it.
Tailscale: 업무용 노트북의 세 가지 잠금 장치
세 장치(두 대의 노트북과 휴대폰) 모두 하나의 개인 Tailscale 네트워크에 연결되어 있습니다. 기본 Tailscale 정책은 모든 장치가 다른 모든 장치에 접근할 수 있도록 허용합니다. 저는 이 설정을 다음과 같이 변경했습니다:
{
"tagOwners": {
"tag:home": ["autogroup:admin"]
...
}
제 장치들은 tag:home 태그가 지정된 기기의 22번 포트에 접근할 수 있습니다. 그 외의 다른 것은 허용되지 않습니다. 해당 태그는 홈 노트북에만 붙어 있습니다. 또한, 태그가 지정된 장치는 키 만료(key expiry) 기능이 꺼져 있어 180일마다 네트워크에서 연결이 끊기지 않습니다.
이것이 작업용 노트북에 걸린 첫 번째 잠금장치입니다. 두 가지가 더 있습니다:
- SSH 서버 비활성화. 시스템 설정(System Settings)에서 원격 로그인(Remote Login)을 꺼둡니다.
- 쉴드 업(Shields Up). Tailscale 앱은 이 장치로 들어오는 연결 요청을 거부합니다.
tailscale set --shields-up=true
tailscale debug prefs | grep -i shields # "ShieldsUp": true
netstat -an -p tcp | grep LISTEN | grep '\.22 ' || echo "no ssh server"
그리고 중요한 확인 사항은 홈 노트북에서 수행하는 것입니다:
nc -vz -G 3 <작업용-노트북> 22 # 실패해야 함
tailscale ping은 여전히 응답할 수 있습니다. Ping은 정책의 적용 범위에 포함되지 않습니다.
Tailscale 자체 SSH 서버는 macOS 앱에서 제공되지 않기 때문에, 홈 노트북은 Mac의 일반 OpenSSH를 사용하며 이는 Tailscale을 통해서만 접근 가능합니다. 작업용 노트북의 SSH 설정 항목은 다음과 같습니다:
Host home-ts
HostName home-laptop
IdentityFile ~/.ssh/id_ed25519_<this-machine>
...
각 장치마다 고유한 키를 사용하며, 해당 장치에서 생성합니다. ssh-copy-id가 성공적으로 작동한 후에는 홈 노트북의 비밀번호 로그인 기능이 비활성화됩니다:
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication no\n' \
| sudo tee /etc/ssh/sshd_config.d/100-keys-only.conf
sudo launchctl kickstart -k system/com.openssh.sshd
이 세션을 열어둔 상태에서 닫기 전에 새로운 ssh를 테스트해 보세요.
Cloudflare Access: 작업용 노트북에 VPN 사용 안 함
다른 방법은 작업용 노트북에서 VPN을 전혀 사용하지 않습니다. 홈 노트북은 릴레이의 터널과는 별개로, SSH 전용 두 번째 cloudflared 터널을 실행합니다. 작업용 노트북의 ssh는 cloudflared access ssh를 통해 연결되며, 이는 아웃바운드(outgoing) 연결만 생성합니다:
Host home
HostName ssh-home.example.dev
IdentityFile ~/.ssh/id_ed25519_<this-machine>
...