Skip to main content

Gitea SSH → HTTPS 마이그레이션과 외부 노출 최소화

포트 32222 정리 요청에서 시작해, 클러스터 전체의 외부 노출 표면을 재평가하고 Teleport 기반 Zero-Trust 구조로 수렴하기까지의 기록. 중간에 발견한 CoreDNS 죽은 설정, Cloudflare 100MB 업로드 제한, mTLS 인증 모델에 대한 정리를 포함합니다.

배경

Lumie 인프라에서 내부 Git 서버(Gitea)는 두 가지 방식으로 외부 노출돼있었습니다.

  • github.lumie-infra.com:443 → Teleport 리버스 프록시 → Gitea (SSO 강제)
  • github.lumie-infra.com:32222 (NodePort) → Gitea SSH

포트 32222가 OCI 보안 리스트와 Gitea NodePort 양쪽에서 의도적으로 열려있어, 개발자들이 SSH 키로 gitea:Lumie-Edu/... 단축 URL로 clone/push하는 패턴을 지원했습니다.

이 작업의 시작점은 단순했습니다.

"포트 32222, 2222 같은 안 쓰는 것들을 정리하고 싶다. 443 하나로 통일할 수 있을까?"

발견 1 — 포트 2222는 false positive

외부에서 github.lumie-infra.com:2222 로 TCP 연결하면 성공했지만:

$ (echo dummy; sleep 2) | nc -w 3 github.lumie-infra.com 2222
# (빈 응답)

$ (echo dummy; sleep 2) | nc -w 3 github.lumie-infra.com 32222
SSH-2.0-Go

애플리케이션 레벨에서 응답하는 건 32222 뿐. 2222는 TCP 핸드셰이크만 성립하고 실제 서비스가 없는 상태로, 정리 대상이 하나(32222)로 줄었습니다.

해결 방향 결정

세 가지 대안을 검토했습니다.

옵션내용선택?
A. Git-over-HTTPS 전용 호스트 추가git.lumie-infra.com Ingress 를 새로 만들고 SSH는 비활성초기 선택
B. sslh 멀티플렉서로 443에 SSH와 HTTPS 공존운영 포인트 증가, 블라스트 반경 ↑기각
C. SSH 유지 + 32222만 닫기현상 유지, 외부 SSH 제공 포기기각

옵션 A가 엔터프라이즈 표준 패턴(GitHub 도 이 방향)에 부합해 채택했습니다.

Phase 1 — Git-over-HTTPS 직통 경로 추가

변경 사항

  1. bootstrap/gitea/helm-values.yaml — SSH 비활성

    gitea:
    config:
    server:
    DISABLE_SSH: true
    service:
    http:
    type: ClusterIP
    port: 3000
    # service.ssh 블록 제거
  2. bootstrap/gitea/manifests/ingress.yaml — 신규 Ingress

    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
    name: gitea-git
    namespace: gitea
    annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    traefik.ingress.kubernetes.io/router.middlewares: gitea-gitea-strip-teleport-headers@kubernetescrd
    spec:
    ingressClassName: traefik
    rules:
    - host: git.lumie-infra.com
    http:
    paths:
    - path: /
    pathType: Prefix
    backend:
    service:
    name: gitea-http
    port:
    number: 3000
    tls:
    - hosts: [git.lumie-infra.com]
    secretName: gitea-git-tls
  3. bootstrap/gitea/manifests/middleware.yaml — 보안 헤더 strip

    Gitea 설정이 REVERSE_PROXY_TRUSTED_PROXIES: "*" + ENABLE_REVERSE_PROXY_AUTHENTICATION: true 이라, 새 경로에서 X-Teleport-User 를 공격자가 주입하면 관리자 위장 가능. 이를 차단:

    apiVersion: traefik.io/v1alpha1
    kind: Middleware
    metadata:
    name: gitea-strip-teleport-headers
    namespace: gitea
    spec:
    headers:
    customRequestHeaders:
    X-Teleport-User: ""
    X-Teleport-Email: ""
    X-Teleport-Roles: ""
  4. provision/terraform/network_0214.tf — OCI 보안 리스트에서 32222 제거

  5. Cloudflare DNSgit.lumie-infra.com CNAME → cluster.lumie-infra.com, grey cloud (proxy off)

    • grey cloud는 Cloudflare의 100MB 업로드 제한을 피하기 위함(발견 2 참고)

함정 — kubernetes_pod 의 Service 충돌

gitea-ssh Service를 Helm이 여전히 일부 형태로 렌더링해 ArgoCD가 기존 NodePort와 충돌했습니다. spec.clusterIPs 는 immutable이라 PATCH가 실패했고, 수동으로 Service를 삭제해 재생성하도록 처리했습니다.

kubectl -n gitea delete svc gitea-ssh
kubectl -n argocd annotate app gitea argocd.argoproj.io/refresh=hard --overwrite

재생성 후에는 ClusterIP None, port 22인 헤드리스 Service만 남고(실제 리스닝 없음) NodePort 외부 노출은 완전히 제거됐습니다.

발견 2 — Cloudflare 100MB 업로드 제한

Tilt로 빌드한 프론트엔드 이미지(351MB)를 Zot에 푸시할 때 다음 에러가 발생:

413 Payload Too Large
PUT request to https://zot.lumie-infra.com/v2/dev/lumie-frontend/blobs/uploads/...

DNS 추적:

$ host zot.lumie-infra.com
zot.lumie-infra.com has address 104.21.91.70
zot.lumie-infra.com has address 172.67.211.95

104.21.x / 172.67.x 는 Cloudflare IP. Cloudflare Free 플랜은 요청당 100MB 업로드 상한이 있어, 그 이상의 blob PUT이 엣지에서 413으로 차단됩니다.

(다운로드 GET은 제한 없음 — kubelet 의 image pull은 문제없이 동작)

CoreDNS 커스텀 ConfigMap 이 실제론 죽어있었음

우선 minio 가 썼던 "내부 split DNS" 패턴을 확인해봤습니다:

# ConfigMap coredns-custom (kube-system)
data:
minio.override: |
rewrite name minio0213.kro.kr traefik.kube-system.svc.cluster.local

그런데 CoreDNS DaemonSet의 볼륨 마운트를 확인하니:

$ kubectl -n kube-system get pod -l k8s-app=kube-dns \
-o jsonpath='{.items[0].spec.containers[0].volumeMounts[*].mountPath}'
/etc/coredns /var/run/secrets/...
  • Corefile에는 import /etc/coredns/custom/*.override 가 있었지만
  • coredns-custom ConfigMap 은 어디에도 마운트되지 않음
  • 즉 기존 minio override는 수년간 죽은 설정이었고, 실제로 minio가 동작했던 건 repo 전체가 minio.minio.svc.cluster.local 를 직접 쓰고 있었기 때문

→ DNS rewrite 수정을 검토하다가 이 죽은 설정을 발견했지만, 최종 해결에는 사용하지 않음 (아래 발견 3 참고).

발견 3 — Zot 은 minio 패턴을 그대로 못 따라함

이론상 minio처럼 모든 image 참조를 zot.zot.svc.cluster.local:5000 으로 바꾸면 CF를 통째로 우회할 수 있습니다. 하지만:

$ ssh k3s 'getent hosts zot.zot.svc.cluster.local'
# (empty — 노드는 cluster svc DNS 해석 불가)

$ ssh k3s 'sudo k3s ctr images pull --plain-http zot.zot.svc.cluster.local:5000/library/busybox:latest'
ctr: failed to resolve image: ... lookup zot.zot.svc.cluster.local: Try again

노드의 /etc/resolv.conf 는 systemd-resolved만 가리키고 CoreDNS가 포함돼있지 않아, kubelet이 cluster 서비스 DNS를 해석하지 못함. Zot URL을 전면 교체하면 전 파드가 ImagePullBackOff.

  • Minio는 consumer가 파드뿐 → CoreDNS 해석 가능 → 내부 URL 일관 사용 가능
  • Zot은 consumer에 kubelet도 포함 → 외부 DNS가 필요 → 완전 내부화는 NodeLocal DNSCache 같은 추가 인프라가 필요

이 구조적 차이가 "왜 minio는 되는데 zot은 안 되나"의 답.

해결 — Cloudflare proxy off로 충분

간단한 경로: cluster.lumie-infra.com (또는 zot CNAME 타겟)을 grey cloud(DNS only) 로 바꾸면 CF 100MB 한도를 피할 수 있습니다. 외부에서는 직접 NLB 로 가므로 DDoS 방어 레이어는 없어지지만, Zot은 이미 htpasswd/OIDC 인증이 걸려있어 실질 위험은 낮음. 소규모 인프라에서는 허용 가능한 트레이드오프.

Phase 2 — git.lumie-infra.com 백도어 제거

Phase 1 직후 재검토에서 중요한 관찰이 있었습니다.

"난 어디서든 tsh를 쓸 거야. 백도어는 필요 없다."

즉 Git 작업을 전부 Teleport 경유로 하겠다면, git.lumie-infra.com 이라는 두 번째 외부 진입점은 공격 표면만 넓힐 뿐 쓸 일이 없습니다.

결과적으로 아래를 전부 제거:

  • Ingress gitea-git, Middleware gitea-strip-teleport-headers
  • cert-manager Certificate gitea-git-tls + TLS Secret
  • ArgoCD source bootstrap/gitea/manifests
  • Cloudflare CNAME git.lumie-infra.com

제거 후 git.lumie-infra.com 으로 접근하면:

$ curl -sI https://git.lumie-infra.com/
HTTP/1.1 302 Found
Location: https://lumie-infra.com:443/web/launch/git.lumie-infra.com?path=%2F

DNS 와일드카드 *.lumie-infra.com → 158.180.89.154 (Teleport 전용 NLB)가 잡아서 Teleport launcher로 리다이렉트됩니다. Gitea 콘텐츠는 노출되지 않고, 쿠키/cert 없는 요청은 그저 "Teleport에 먼저 로그인하라"는 페이지만 받게 됩니다.

발견 4 — mTLS 와 Teleport의 두 단계 인증

진행 중 Teleport의 인증 모델을 정확히 정리했습니다.

tsh login vs tsh app login

  • tsh login → 클러스터 사용자 cert 발급. "내가 bluemayne 이다" 증명
  • tsh app login gitea → app 별 client cert 발급. "bluemayne 이고 이 app에 접근 인가됨"

이 두 단계는 중복이 아니라 identity + authorization 분리:

  • per-app MFA(require_session_mfa) 적용 여지
  • app cert의 짧은 수명(보통 8h)과 스코프 제한
  • "쓸 cert만 받기" 원칙(lazy 발급)
  • 감사 로그의 정밀도(로그인 이벤트 vs 앱 접근 이벤트 분리)

실무에선 셸 wrapper로 묶어 한 번에 실행:

tspin() {
tsh login --proxy=lumie-infra.com || return 1
for app in gitea coder vault grafana argocd zot keycloak; do
tsh app login $app 2>/dev/null
done
}

서버 cert vs 클라이언트 cert

Teleport proxy는 mTLS:

  • Server cert (teleport-tls) — Let's Encrypt 발급, *.lumie-infra.com 와일드카드, cert-manager DNS-01 via Cloudflare로 자동 갱신
  • Client cert — Teleport 내부 CA 발급, ~/.tsh/keys/.../gitea.crt, 짧은 수명, 유저명이 cert에 박혀있음

tsh app login 이 발급하는 건 client cert. tsh proxy app 은 "cert를 다룰 줄 모르는 클라이언트(브라우저, docker)" 를 위한 로컬 어댑터로, git처럼 http.sslCert 를 직접 지원하는 도구는 프록시 없이 cert를 직접 써도 됩니다.

최종 구성

외부 노출 인프라 도메인(전체)

호스트경로용도
zot.lumie-infra.comCF(grey) → NLB(168.107.42.253) → Traefik → Zotkubelet image pull 전용, CF proxy off
dev.lumie-infra.com 등 앱 IngressCF(orange) → NLB(168.107.42.253) → Traefik앱 트래픽
lumie-edu.com 세트CF(orange) → NLB(168.107.42.253) → Traefik엔드유저 서비스
*.lumie-infra.com (infra 툴)Teleport NLB(158.180.89.154)Vault, Grafana, ArgoCD, Gitea UI 등 — Teleport 인증 필수

이 외 모든 *.lumie-infra.com 호스트는 Teleport 뒤에 있거나 와일드카드로 Teleport launcher 로 리다이렉트됩니다. Vault, Grafana, ArgoCD, Keycloak(infra 렐름), MinIO, RabbitMQ, Coder, Gitea UI 등 인프라/관리 UI 전체가 여기 해당.

Git 접근 경로

Mac (로컬)

tsh login --proxy=lumie-infra.com # OTP
tsh app login gitea
git clone https://github.lumie-infra.com/Lumie-Edu/lumie-infra.git

~/.gitconfig 에 영구 설정:

[http "https://github.lumie-infra.com/"]
sslCert = /Users/bluemayne/.tsh/keys/lumie-infra.com/bluemayne-app/lumie-infra.com/gitea.crt
sslKey = /Users/bluemayne/.tsh/keys/lumie-infra.com/bluemayne-app/lumie-infra.com/gitea.key

git clone/push 평소처럼, mTLS 자동 적용.

Coder 워크스페이스 (클러스터 내부)

git clone https://github.lumie-infra.com/Lumie-Edu/lumie-infra.git

~/.gitconfig 영구 설정:

[url "http://gitea-http.gitea.svc.cluster.local:3000/"]
insteadOf = https://github.lumie-infra.com/
[credential]
helper = store

~/.git-credentials (최초 1회):

http://Mayne0213:<password>@gitea-http.gitea.svc.cluster.local:3000

→ Teleport 우회, cluster service 직접 접근. credential 프롬프트 없음, PVC 영속.

외부 PC (tsh 미설치) 실질적으로 불가. Teleport 설치 + OTP 필요.

검증

# 외부에서 git 포트 폐쇄 확인
$ nc -zv github.lumie-infra.com 32222
→ TCP 핸드셰이크 흉내(false positive), 앱 응답 없음

# Teleport 경유 clone (Mac)
$ git clone https://github.lumie-infra.com/Lumie-Edu/lumie-infra.git
Cloning into 'lumie-infra'... (프롬프트 없이 완료)

# 워크스페이스 push (cluster-internal)
$ git push
remote: Processing 1 references
* [new branch] HEAD -> test-branch

교훈

1. "내부망"은 DNS 레이어에서 정의된다

엔터프라이즈 온프렘에서 내부망 통합의 핵심은 split-horizon DNS입니다. 같은 호스트명이 네트워크 위치에 따라 다른 IP로 해석되면서 애플리케이션은 호스트명 하나만 알면 됩니다. Lumie는 Cloudflare(외부 SaaS)를 DNS 권위로 쓰기 때문에 이런 이분화가 불가능하고, 대신 Teleport가 사용자 레이어의 split-horizon을 담당합니다.

  • 사람: Teleport SSO를 통과하면 내부 서비스에 접근
  • Pod-to-pod: cluster svc DNS
  • kubelet: 공용 DNS(Cloudflare)

완벽한 내부망을 원한다면 NodeLocal DNSCache + kubelet의 cluster DNS 통합이 다음 단계 옵션.

2. 죽은 설정은 조용히 쌓인다

coredns-custom ConfigMap의 rewrite 룰이 실제로 반영되지 않고 있었던 걸 이번에 발견했습니다. 설정만 보면 동작할 것 같지만 DaemonSet 볼륨 마운트 누락으로 전혀 효과가 없는 상태가 상당 기간 유지됐습니다. 교훈:

  • "설정이 있다" ≠ "설정이 동작한다"
  • 가끔 pod 레벨에서 ls /etc/coredns/ 처럼 실제 파일 구조를 확인해야 함
  • GitOps로 관리되지 않는 kubectl apply 기반 리소스는 이런 drift의 온상이 됨

3. 두 경로 중 하나를 지울 용기

Phase 1에서 git.lumie-infra.com 을 추가했다가 Phase 2에서 전부 제거한 건 전형적인 YAGNI 실수 교정이었습니다. "혹시 외부에서 tsh 없이 clone 할 일이 있지 않을까" 라는 가정으로 추가했지만, 실제 요구사항(= 관리자 본인의 워크플로우)은 tsh 를 항상 쓰므로 백도어는 공격 표면만 늘리는 잉여였습니다.

인프라에서 "혹시 모르니까 열어두는 경로"는 거의 언제나 잘못된 결정입니다. 실제 요구가 생겼을 때 그때 다시 추가하는 비용보다, 불필요한 노출의 누적 위험이 더 큽니다.

4. mTLS의 두 단계는 기능이다, 버그가 아니다

tsh login + tsh app login X 가 번거로워 보이지만, identity와 authorization을 분리하는 구조는:

  • 관리자에게는 셸 wrapper 한 줄의 편의로 해결되고
  • 일반 사용자에게는 최소 권한 원칙의 강제 장치로 기능

당장 혼자 쓰더라도 팀 확장을 고려하면 이 분리는 자산.

관련 커밋

  • FEAT(gitea): add HTTPS ingress, disable SSH — Phase 1
  • REVERT(gitea): drop git.lumie-infra.com direct-exposure ingress — Phase 2
  • 관련 파일: bootstrap/gitea/helm-values.yaml, bootstrap/gitea/argocd.yaml, provision/terraform/network_0214.tf

후속 과제

  • coredns-custom ConfigMap을 GitOps로 이관(이번엔 내용 변경 없이 보존만)
  • NodeLocal DNSCache 도입 검토 — Zot 을 포함한 모든 서비스를 cluster svc URL로 통일할 수 있음
  • Gitea admin-tmp2 임시 계정 정리 및 Vault password 강화(현재 10자)
  • OCI NLB public IP의 private_ip_id drift 복구