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 직통 경로 추가
변경 사항
-
bootstrap/gitea/helm-values.yaml— SSH 비활성gitea:config:server:DISABLE_SSH: trueservice:http:type: ClusterIPport: 3000# service.ssh 블록 제거 -
bootstrap/gitea/manifests/ingress.yaml— 신규 IngressapiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: gitea-gitnamespace: giteaannotations:cert-manager.io/cluster-issuer: letsencrypt-prodtraefik.ingress.kubernetes.io/router.middlewares: gitea-gitea-strip-teleport-headers@kubernetescrdspec:ingressClassName: traefikrules:- host: git.lumie-infra.comhttp:paths:- path: /pathType: Prefixbackend:service:name: gitea-httpport:number: 3000tls:- hosts: [git.lumie-infra.com]secretName: gitea-git-tls -
bootstrap/gitea/manifests/middleware.yaml— 보안 헤더 stripGitea 설정이
REVERSE_PROXY_TRUSTED_PROXIES: "*"+ENABLE_REVERSE_PROXY_AUTHENTICATION: true이라, 새 경로에서X-Teleport-User를 공격자가 주입하면 관리자 위장 가능. 이를 차단:apiVersion: traefik.io/v1alpha1kind: Middlewaremetadata:name: gitea-strip-teleport-headersnamespace: giteaspec:headers:customRequestHeaders:X-Teleport-User: ""X-Teleport-Email: ""X-Teleport-Roles: "" -
provision/terraform/network_0214.tf— OCI 보안 리스트에서 32222 제거 -
Cloudflare DNS —
git.lumie-infra.comCNAME →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-customConfigMap 은 어디에도 마운트되지 않음- 즉 기존 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, Middlewaregitea-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.com | CF(grey) → NLB(168.107.42.253) → Traefik → Zot | kubelet image pull 전용, CF proxy off |
dev.lumie-infra.com 등 앱 Ingress | CF(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의 온상이 됨