소울을 묻기도 전에
앞의 세 레슨은 어느 소울이 어느 사진을 볼 수 있는지 정했어. 이번엔 어느 기기가 스튜디오와 말을 섞을 수나 있는지 정해. app.py의 미들웨어 함수 하나가 모든 요청에서 어떤 라우트보다 먼저 돌고, 검사를 정해진 순서로 해. 네트워크가 먼저고, 이어서 경로별 규칙, 마지막이 관리자 확인이야. 하나에서 떨어진 요청은 다음 관문에 닿지도 못해.
관문 1: 네트워크
require_family_network는 TCP 상대 주소를 소켓에서 읽어. 헤더에선 절대 안 읽어. 허용하는 건 셋이야. 루프백, Tailscale의 IPv4 대역 100.64.0.0/10(RFC 6598이 통신사급 NAT용으로 떼어 둔 공유 주소 공간), Tailscale의 IPv6 접두사 fd7a:115c:a1e0::/48. 나머지는 집 LAN 주소까지 포함해서 전부 403과 함께 Use the family Tailscale connection.을 받아. 가족 Tailscale 연결로 들어오라는 말이야.
백엔드 테스트는 클라이언트 둘로 요점을 보여 줘. 집 LAN에 있는 상대가 x-forwarded-for: 127.0.0.1을 보내도 403을 받아. 이 관문은 어떤 헤더로도 안 열리거든. tailnet 상대, 예를 들어 100.64.0.7은 /api/health에선 200을, /internal/providers에선 403을 받아. 내부 경로엔 따로 규칙이 있어서야.
헤더가 의미를 갖는 곳은 client_ip 딱 한 군데야. TCP 상대가 루프백일 때만 x-forwarded-for를 믿어. 스튜디오의 HTTPS 쌍둥이를 실어 나르는 Tailscale Serve의 로컬 프록시가 바로 그렇게 들어오거든. Serve는 tailnet 안에서만 답해. 그래서 쌍둥이를 거쳐 온 브라우저는 루프백으로 관문 1을 넘고, 진짜 주소는 헤더에 실려 권위 있는 쪽의 판단을 받으러 가.
내부 경로는 이 맥의 엔진 몫이야
/internal/ 아래에선 미들웨어가 require_internal을 불러. 상대는 루프백이어야 하고, 요청엔 x-forwarded-for, origin, sec-fetch-mode가 하나도 없어야 해. Serve는 첫 번째를 붙이니까 HTTPS 쌍둥이로 들어온 건 하나도 못 지나가. 맥에서 바로 연 브라우저는 세 번째에 걸려. 요즘 브라우저는 루프백 주소로 가는 요청마다 sec-fetch-mode를 붙이거든. origin은 그보다 약한 검사야. 같은 출처의 GET이나 HEAD, 그리고 이미지 로드 같은 no-cors GET엔 브라우저가 안 붙여. 그래서 교차 출처 호출과 POST는 잡아도 모든 요청을 잡진 못해. 결국 여기선 관문이 Fetch Metadata에 기대는 거야. 그게 나오기 전의 낡은 브라우저로 맥에서 내부 URL을 바로 열면 셋 중 아무것도 안 붙어. 그 구석만 빼면, 남는 건 같은 기계에서 도는 평범한 HTTP 클라이언트뿐이야. 피파가 그렇고, 루프백에서 끝나는 터널을 타고 오는 사이트의 내보내기 관문이 그래. actor_soul_id를 받는 문이 바로 여기고, 맥 밖에선 아무도 두드릴 수 없어.
관문 2: 같은 출처
/api/ 경로는 전부 그다음 same_origin을 거쳐. sec-fetch-site가 cross-site인 요청도, Origin이 Host 헤더와 다른 호스트를 대는 요청도 거절해. 아빠 브라우저에 열린 다른 페이지가 아빠 세션으로 스튜디오를 조종할 수는 없어. 세션 쿠키도 SameSite=strict라서, 교차 사이트 요청엔 애초에 실리지도 않아. 문 하나에 자물쇠가 따로 두 개 달린 셈이야.
관문 3: 이 사람이 아빠야?
/api/ 경로는 넷만 빼고 전부 관리자 검사를 거쳐야 해. 빠지는 넷은 /api/health, /api/config, /api/auth, /api/auth/status야. 잠금을 풀기 전의 기기가 닿아야 하는 것들이지. 상태 확인, 앱 설정, PIN 입력, 잠금 상태. require_admin은 권위 있는 곳 두 군데에 물어. Avatars는 사용자도, PIN도, 세션도 자기가 따로 들고 있지 않아.
- Firelink의 가족 파사드가 기기의 보안 상태를 알려 줘. 차단된 기기거나 깊은 잠금 상태면 403이야. PIN이 켜져 있는데 이 기기가 인증 전이면 401과 함께
Unlock with the family PIN.이 와. 가족 PIN으로 잠금을 풀라는 뜻이야. - 피파의
/api/users가 지금 사용자를 알려 줘.is_admin능력이 없으면 403과 함께Avatar administration is available to Dad.가 와. 아바타 관리는 아빠만 할 수 있다는 말이야.
두 호출 모두 찾아온 기기 자신의 헤더를 실어. 풀어낸 주소를 x-forwarded-for로, 그리고 출처, fetch 사이트, 쿠키, 피파 세션 헤더까지. 모듈 docstring이 한 줄로 말해. Avatars는 실제로 찾아온 기기에 대해 가족 권위에 묻는다고. 이걸 다 지나야 요청이 행위자 없이 사진을 읽는 스튜디오 라우트에 닿아. 첫 레슨에서 본, 모든 소울을 보는 관리자 시야가 바로 거기야.
PIN과 쿠키
POST /api/auth는 킷의 verify_pin_verdict로 PIN을 가족 권위에 넘기면서, 기기의 주소를 클라이언트로 지목해. 권위는 발급하는 세션을 그 주소에 묶어. 이게 없으면 엔진 자신의 루프백이 모든 기기를 대신해서 답하게 돼. 그러면 어디서든 틀린 PIN 다섯 번이면 루프백이 블랙리스트에 오르고, 그 뒤로 엔진을 거치는 PIN은 전부 거절돼. 검증자에 닿지 못하면 503이지, 틀린 PIN으로 치지 않아. 성공하면 스튜디오는 pippa_session을 HttpOnly, SameSite=strict로 심어. 요청이 HTTPS였거나, 루프백 프록시를 거쳐 x-forwarded-proto: https로 왔으면 Secure도 붙이고. PIN 층과, 첫 방어선 노릇을 하는 Tailscale은 Solo Auth Quest에서 직접 만들어 봐. 이 레슨은 형제 앱이 그걸 가져다 쓰는 모습만 보여 줘.
연결은 같이, 자격 증명은 따로
Avatars는 업스트림 호출에 풀링된 HTTP 클라이언트 하나를 써. 연결은 최대 16개야. 풀링이 안전한 건 호출마다 받은 헤더만으로 요청을 새로 만들기 때문이야. 주석에도 그렇게 적혀 있어. 연결은 나눠 쓰지만, 기기마다의 자격 증명은 나누지 않는다고. 테스트가 그걸 증명해. 업스트림이 Set-Cookie로 답해도, 쿠키 없이 보낸 다음 요청엔 쿠키가 하나도 없어. 여기 클라이언트 수준의 쿠키 저장소가 있었다면, 한 기기의 세션이 다른 기기의 요청에 조용히 올라탔을 거야.
그리고 라우트까지 간 응답엔 전부 X-Content-Type-Options: nosniff가 붙어. /api/와 /internal/ 아래 응답엔 Cache-Control: private, no-store도 붙어. 보호받는 답의 사본을 공유 캐시가 쥐고 있지 않게 하려는 거야. 관문이 직접 거절한 답엔 둘 다 안 붙어. 관문이 예외를 던지면 except 갈래가 새 JSONResponse를 만들어 바로 돌려주는데, 그건 헤더를 붙이는 두 줄을 안 지나가거든. 그런 답엔 오류 메시지만 있고 사진은 없으니 보호받는 게 새진 않아. 그래도 이 헤더는 모든 응답이 아니라 라우트까지 간 응답의 몫이야.