Streamlit 운영 대시보드 NAS 배포 — 단일 URL + Tailscale 자동 감지 #44

Closed
opened 2026-04-23 01:29:37 +09:00 by xhh · 0 comments
Owner

#22 에서 Streamlit 3페이지 구현은 완료됐지만 NAS 배포 + 외부 접근 경로 는 의도적으로 스코프 밖으로 뺐다. 이 이슈에서 마무리.

인증 방향 결정

단일 URL (stock-admin.xhhan.com) + 클라이언트 JS Tailscale 프로브. 외부 공개로 조회 페이지를 열고, Tailscale 이 켜져 있는 브라우저에 한해 Manual Trigger UI 가 자동 활성.

  • 공개 접근: Collection History / Data Coverage — 읽기 전용, 컨테이너 env 의 read API 키로 자동 조회
  • Tailscale 접근: Manual Trigger UI 추가 노출 — write API 키는 사이드바에서 직접 입력해야 동작
  • 실제 보안 게이트는 write API 키. UI 노출은 UX 힌트일 뿐이라 클라이언트 스푸핑 가능해도 실제 트리거는 서버가 거부.

아키텍처

flowchart TB
    u[사용자 브라우저] -->|stock-admin.xhhan.com| cf[Cloudflare Tunnel]
    cf --> tr[Traefik] --> st[Streamlit 컨테이너<br/>:8501<br/>DEFAULT_READ_API_KEY env]
    st -->|HTML + 작은 JS| u
    u -.->|JS fetch<br/>http://100.100.100.100<br/>Tailscale 로컬 API| ts{응답?}
    ts -->|성공 = Tailscale on| admin[Admin UI<br/>Manual Trigger 노출]
    ts -->|타임아웃 = Tailscale off| pub[Public UI<br/>조회 페이지만]
    admin --> api[FastAPI :8000<br/>write 요청]
    pub --> api
    api --> db[(SQLite)]
    api -.->|write 키 필요| admin

    classDef public fill:#fff9c4,stroke:#f9a825,color:#e65100
    classDef priv fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
    classDef infra fill:#e3f2fd,stroke:#1976d2,color:#0d47a1

    class u,cf,pub public
    class admin priv
    class tr,st,api,db,ts infra

Tailscale 프로브 원리

  • Tailscale 클라이언트가 실행 중이면 로컬 100.100.100.100 에 리스닝. 브라우저에서 fetch("http://100.100.100.100/", {mode: "no-cors", signal: AbortSignal.timeout(800)}) 로 짧은 시도.
  • 응답 / CORS 에러 = Tailscale on → admin 모드
  • 타임아웃 = Tailscale off → public 모드
  • 결과를 st.session_state 에 저장. Manual Trigger 페이지가 이 값을 읽어 st.stop() / 정상 렌더 분기.

왜 클라이언트 검사로 충분한가

UI 노출 여부는 편의성일 뿐, 실제 write 동작은 write API 키가 담당. 이 키는 컨테이너 env 에 없음 → 사이드바에서 직접 입력해야 하고, Tailscale 안 켜고 억지로 Manual Trigger 페이지를 띄워도 키가 없어 아무 동작 안 함. 서버 측 보안과 클라이언트 UX 가 분리되어 있어 스푸핑 내성 자체는 API 가 담당한다.

구현할 것

1) Streamlit 앱 코드 수정

  • utils/api.pyDEFAULT_READ_API_KEY 환경변수 폴백. 사이드바에 키가 없고 env 에 read 키가 있으면 자동 사용.
  • utils/tailscale_probe.py (신규) — Streamlit components.v1.html 로 JS 프로브 삽입. 결과를 st.session_state["tailscale_on"] 으로 저장. 재호출시 캐시.
  • app.py — 사이드바에 🔓 Tailscale 감지됨 / 🌐 공개 모드 배지 표시.
  • pages/6_Manual_Trigger.py — 페이지 상단에서 st.session_state.get("tailscale_on") 이 False 면 안내 문구 + st.stop(). True 면 기존 UI.
  • 회귀 테스트: session_state mock 으로 public/admin 분기 검증 (JS 동작 자체는 단위 테스트 대상 아님).

2) Streamlit 컨테이너화

  • frontend/streamlit/Dockerfile — python:3.13-slim + uv sync + streamlit.
  • docker-compose.ymlstreamlit 서비스 추가.
  • 환경변수: API_URL=http://api:8000, DEFAULT_READ_API_KEY=<Secrets 주입>.
  • healthcheck: curl -fsS http://localhost:8501/_stcore/health.

3) Traefik 라우팅 + Cloudflare

  • docker-compose.yml streamlit 서비스에 Traefik 라벨 추가.
  • Host 규칙: stock-admin.xhhan.com.
  • traefik-public 네트워크 조인.
  • Cloudflare Tunnel 에 stock-admin.xhhan.com 서비스 매핑 추가 (운영자 작업).
  • DNS: Cloudflare 에 stock-admin CNAME (운영자 작업).

4) 최초 배포 플레이북

  • read-only API 키 발급: uv run python scripts/apikey.py create --name "streamlit-public" --scopes "read" → 원문 키를 Forgejo Secrets (DEFAULT_READ_API_KEY) 등록
  • NAS 에 docker compose up -d --build streamlit
  • 외부 브라우저 (Tailscale off) 에서 https://stock-admin.xhhan.com: 사이드바에 🌐 공개 모드, Manual Trigger 페이지 입장 시 차단 문구
  • Tailscale on 상태에서 같은 URL: 사이드바에 🔓 Tailscale 감지됨, Manual Trigger 정상 렌더. write 키 입력 후 버튼 동작 확인

완료 기준

  • stock-admin.xhhan.com 단일 URL
  • Tailscale off: 조회 2페이지 자동 로드, Manual Trigger 차단
  • Tailscale on: 같은 URL 에서 Manual Trigger 자동 활성, write 키 입력 후 트리거 성공
  • JS 프로브 실패 / 브라우저 mixed-content 차단 같은 edge case 에서 안전하게 public 으로 fallback (이 경우 write 키 있어도 UI 가 안 보임 — 사이드바에 수동 'admin 강제' 토글 1개 추가 고려)

스푸핑 / 우회 시나리오 정리

공격 결과
외부 공격자가 tailscale_on=true 를 JS 로 강제 Manual Trigger UI 는 보임. 하지만 write 키 없음 → 트리거 API 가 401/403 반환
write 키가 유출되면? scope 가 read/write 로 나뉘므로 read 키만 공개. write 키는 본인 사이드바 입력 시에만 쓰임
read 키가 유출되면? 원래 외부 공개용이라 피해 없음. 필요 시 apikey.py rotate 로 회전

선행

  • #22 (Streamlit 3페이지 구현) — 완료

참고

  • /api/health#14 에서 503 반환하도록 수정됨 → compose healthcheck 자동 감지
  • 기존 api 서비스 Runner (nas 라벨) 재사용
  • Cloudflare Access / Tailscale IdP 같은 외부 서비스 추가 불필요
#22 에서 Streamlit 3페이지 구현은 완료됐지만 **NAS 배포 + 외부 접근 경로** 는 의도적으로 스코프 밖으로 뺐다. 이 이슈에서 마무리. ## 인증 방향 결정 **단일 URL (`stock-admin.xhhan.com`) + 클라이언트 JS Tailscale 프로브**. 외부 공개로 조회 페이지를 열고, Tailscale 이 켜져 있는 브라우저에 한해 Manual Trigger UI 가 자동 활성. - 공개 접근: Collection History / Data Coverage — 읽기 전용, 컨테이너 env 의 read API 키로 자동 조회 - Tailscale 접근: Manual Trigger UI 추가 노출 — write API 키는 사이드바에서 직접 입력해야 동작 - 실제 보안 게이트는 **write API 키**. UI 노출은 UX 힌트일 뿐이라 클라이언트 스푸핑 가능해도 실제 트리거는 서버가 거부. ## 아키텍처 ```mermaid flowchart TB u[사용자 브라우저] -->|stock-admin.xhhan.com| cf[Cloudflare Tunnel] cf --> tr[Traefik] --> st[Streamlit 컨테이너<br/>:8501<br/>DEFAULT_READ_API_KEY env] st -->|HTML + 작은 JS| u u -.->|JS fetch<br/>http://100.100.100.100<br/>Tailscale 로컬 API| ts{응답?} ts -->|성공 = Tailscale on| admin[Admin UI<br/>Manual Trigger 노출] ts -->|타임아웃 = Tailscale off| pub[Public UI<br/>조회 페이지만] admin --> api[FastAPI :8000<br/>write 요청] pub --> api api --> db[(SQLite)] api -.->|write 키 필요| admin classDef public fill:#fff9c4,stroke:#f9a825,color:#e65100 classDef priv fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20 classDef infra fill:#e3f2fd,stroke:#1976d2,color:#0d47a1 class u,cf,pub public class admin priv class tr,st,api,db,ts infra ``` ### Tailscale 프로브 원리 - Tailscale 클라이언트가 실행 중이면 로컬 `100.100.100.100` 에 리스닝. 브라우저에서 `fetch("http://100.100.100.100/", {mode: "no-cors", signal: AbortSignal.timeout(800)})` 로 짧은 시도. - 응답 / CORS 에러 = Tailscale on → admin 모드 - 타임아웃 = Tailscale off → public 모드 - 결과를 `st.session_state` 에 저장. Manual Trigger 페이지가 이 값을 읽어 `st.stop()` / 정상 렌더 분기. ### 왜 클라이언트 검사로 충분한가 UI 노출 여부는 편의성일 뿐, **실제 write 동작은 write API 키가 담당**. 이 키는 컨테이너 env 에 없음 → 사이드바에서 직접 입력해야 하고, Tailscale 안 켜고 억지로 Manual Trigger 페이지를 띄워도 키가 없어 아무 동작 안 함. 서버 측 보안과 클라이언트 UX 가 분리되어 있어 스푸핑 내성 자체는 API 가 담당한다. ## 구현할 것 ### 1) Streamlit 앱 코드 수정 - [ ] `utils/api.py` — `DEFAULT_READ_API_KEY` 환경변수 폴백. 사이드바에 키가 없고 env 에 read 키가 있으면 자동 사용. - [ ] `utils/tailscale_probe.py` (신규) — Streamlit `components.v1.html` 로 JS 프로브 삽입. 결과를 `st.session_state["tailscale_on"]` 으로 저장. 재호출시 캐시. - [ ] `app.py` — 사이드바에 `🔓 Tailscale 감지됨` / `🌐 공개 모드` 배지 표시. - [ ] `pages/6_Manual_Trigger.py` — 페이지 상단에서 `st.session_state.get("tailscale_on")` 이 False 면 안내 문구 + `st.stop()`. True 면 기존 UI. - [ ] 회귀 테스트: `session_state` mock 으로 public/admin 분기 검증 (JS 동작 자체는 단위 테스트 대상 아님). ### 2) Streamlit 컨테이너화 - [ ] `frontend/streamlit/Dockerfile` — python:3.13-slim + `uv sync` + streamlit. - [ ] `docker-compose.yml` 에 `streamlit` 서비스 추가. - [ ] 환경변수: `API_URL=http://api:8000`, `DEFAULT_READ_API_KEY=<Secrets 주입>`. - [ ] healthcheck: `curl -fsS http://localhost:8501/_stcore/health`. ### 3) Traefik 라우팅 + Cloudflare - [ ] `docker-compose.yml` streamlit 서비스에 Traefik 라벨 추가. - [ ] Host 규칙: `stock-admin.xhhan.com`. - [ ] `traefik-public` 네트워크 조인. - [ ] Cloudflare Tunnel 에 `stock-admin.xhhan.com` 서비스 매핑 추가 (운영자 작업). - [ ] DNS: Cloudflare 에 `stock-admin` CNAME (운영자 작업). ### 4) 최초 배포 플레이북 - [ ] read-only API 키 발급: `uv run python scripts/apikey.py create --name "streamlit-public" --scopes "read"` → 원문 키를 Forgejo Secrets (`DEFAULT_READ_API_KEY`) 등록 - [ ] NAS 에 `docker compose up -d --build streamlit` - [ ] 외부 브라우저 (Tailscale off) 에서 `https://stock-admin.xhhan.com`: 사이드바에 `🌐 공개 모드`, Manual Trigger 페이지 입장 시 차단 문구 - [ ] Tailscale on 상태에서 같은 URL: 사이드바에 `🔓 Tailscale 감지됨`, Manual Trigger 정상 렌더. write 키 입력 후 버튼 동작 확인 ## 완료 기준 - [ ] `stock-admin.xhhan.com` 단일 URL - [ ] Tailscale off: 조회 2페이지 자동 로드, Manual Trigger 차단 - [ ] Tailscale on: 같은 URL 에서 Manual Trigger 자동 활성, write 키 입력 후 트리거 성공 - [ ] JS 프로브 실패 / 브라우저 mixed-content 차단 같은 edge case 에서 안전하게 public 으로 fallback (이 경우 write 키 있어도 UI 가 안 보임 — 사이드바에 수동 'admin 강제' 토글 1개 추가 고려) ## 스푸핑 / 우회 시나리오 정리 | 공격 | 결과 | |---|---| | 외부 공격자가 `tailscale_on=true` 를 JS 로 강제 | Manual Trigger UI 는 보임. 하지만 write 키 없음 → 트리거 API 가 401/403 반환 | | write 키가 유출되면? | scope 가 read/write 로 나뉘므로 read 키만 공개. write 키는 본인 사이드바 입력 시에만 쓰임 | | read 키가 유출되면? | 원래 외부 공개용이라 피해 없음. 필요 시 `apikey.py rotate` 로 회전 | ## 선행 - #22 (Streamlit 3페이지 구현) — 완료 ## 참고 - `/api/health` 는 #14 에서 503 반환하도록 수정됨 → compose healthcheck 자동 감지 - 기존 `api` 서비스 Runner (`nas` 라벨) 재사용 - Cloudflare Access / Tailscale IdP 같은 외부 서비스 추가 불필요
xhh changed title from Streamlit 운영 대시보드 NAS 배포 + 인증 방식 결정 to Streamlit 운영 대시보드 NAS 배포 — 공개 조회 + Tailscale 컨트롤 하이브리드 2026-04-23 01:39:05 +09:00
xhh changed title from Streamlit 운영 대시보드 NAS 배포 — 공개 조회 + Tailscale 컨트롤 하이브리드 to Streamlit 운영 대시보드 NAS 배포 — 단일 URL + Tailscale 자동 감지 2026-04-23 01:48:48 +09:00
xhh closed this issue 2026-04-23 02:13:27 +09:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
xhh/financial-data-platform#44
No description provided.