아무 서브도메인이나 치면 하얀 화면이 떠서 catchall 페이지를 만들었다
와일드카드 DNS와 Apache 기본 vhost 때문에 없는 서브도메인이 블로그로 들어가 빈 화면이 떴다. 안내 페이지로 바꾸면서 파일 순서, 와일드카드 인증서, 상태 코드와 noindex를 어떻게 정했는지 정리했다.
#Apache #인프라 #리버스프록시 #DNS #운영
내 서버에는 서브도메인이 열 개 가까이 붙어 있다. blog, coin, travel, couple, game, taro, fishing 식으로 서비스마다 하나씩 주고, Apache가 들어온 도메인을 보고 각자의 포트로 넘겨준다. 이 구조는 예전 글 에 정리해뒀다. 하루는 폰으로 블로그 주소를 치다가 오타를 냈다. blgo.jaeyonging.com 으로 들어갔는데 "사이트에 연결할 수 없음" 같은 에러가 아니라 하얀 화면이 떴다. 근데 탭 제목은 내 블로그 제목이었다. 혹시 해서 asdfas.jaeyonging.com 처럼 아무렇게나 쳐봐도 똑같았다. 아무 주소나 치면 내 블로그 껍데기가 열리고, 내용은 안 나왔다. 이 글에서는 왜 그런 일이 생겼는지, 그리고 지금처럼 등록 안 된 서브도메인으로 들어오면 미리 만들어둔 안내 화면이 뜨게 바꾸면서 했던 고민들을 정리해본다. 왜 아무 주소나 열렸나 원인은 두 군데에 있었다. 먼저 DNS다. 도메인은 가비아에서 관리하는데, 서비스를 하나 올릴 때마다 레코드를 추가하는 게 번거로워서 예전에 * 레코드 하나로 모든 서브도메인이 내 서버 IP를 보게 해뒀다. 그래서 세상에 없는 서브도메인도 전부 내 서버까지 온다. 서버 IP는 가렸지만 셋 다 같은 주소가 나온다. $ dig +short jaeyonging.com xxx.xxx.xxx.xxx $ dig +short asdfas.jaeyonging.com xxx.xxx.xxx.xxx $ dig +short nonexist123.jaeyonging.com xxx.xxx.xxx.xxx 그다음은 Apache다. 이름 기반 가상호스트는 요청의 Host 헤더, HTTPS라면 SNI를 보고 ServerName 이나 ServerAlias 가 맞는 vhost를 찾는다. 근데 맞는 게 하나도 없으면 에러를 내는 게 아니라 그 포트에서 제일 먼저 읽은 vhost 로 보낸다. 이게 기본 vhost다. 제일 먼저 읽는 건 sites-enabled 안의 파일 이름 순서로 정해진다. 내 경우엔 blog로 시작하는 파일이 맨 앞이었다. 블로그를 기본값으로 정한 적은 한 번도 없는데, b가 알파벳 앞쪽이라는 이유만으로 어디에도 안 걸린 요청을 전부 블로그가 받고 있었던 거다. apache2ctl -S 를 쳐보면 포트마다 default server가 뭔지 나오니까, 서브도메인을 여러 개 쓰고 있다면 한 번 확인해보길 추천한다. 그럼 하얀 화면은 왜 떴나 블로그가 그대로 떴으면 차라리 덜 이상했을 텐데 화면이 비어 있었다. 개발자 도구를 열어보니 HTML은 받았는데 JS 파일이 500이었다. GET https://asdfas.jaeyonging.com/assets/index-e215cf4d.js 500 (Internal Server Error) 블로그 서버의 CORS 설정 때문이다. Vite가 만든 스크립트는 type="module" 로 붙는데, 모듈 스크립트는 같은 출처라도 Origin 헤더를 달고 요청된다. Express의 cors 미들웨어는 허용목록에 없는 Origin이 오면 에러를 던지고, 그게 그대로 500이 된다. 오타 난 호스트가 허용목록에 있을 리가 없으니 JS가 하나도 안 내려가고 껍데기만 남았던 거다. $ curl -s -H 'Origin: https://asdfas.jaeyonging.com' \ http://localhost:5000/assets/AdminBlog-3568ff89.js | head -c 120 !DOCTYPE html html lang="en" head meta charset="utf-8" title Error /title /head body pre Error: Not allowed by CORS curl은 Origin을 안 붙이니까 그냥 치면 200이 나온다. 브라우저에서만 깨지는 거라 처음엔 원인을 찾느라 좀 헤맸다. 선택지를 몇 개 놓고 따져봤다 가장 쉬운 건 CORS 허용목록에 *.jaeyonging.com 을 넣는 거였다. 근데 그러면 하얀 화면 대신 블로그가 끝없이 많은 주소로 열리게 된다. 마침 애드센스 심사 중이라 같은 내용이 여러 주소로 보이는 건 최대한 줄이고 싶었다. 그리고 블로그가 기본값인 것 자체가 우연이었다. 나중에 a로 시작하는 서비스를 하나 추가하면 그날부터 모든 오타 주소가 그 서비스로 간다. 설정 파일 하나 추가했는데 엉뚱한 데가 바뀌는 구조는 언젠가 분명 나를 헷갈리게 한다. 와일드카드 DNS를 지우는 방법도 있었다. 오타는 DNS 단계에서 끝나니 제일 깔끔하다. 대신 서비스를 올릴 때마다 가비아에 들어가서 레코드를 추가하고 전파를 기다려야 한다. 와일드카드로 바꾼 이유가 그게 귀찮아서였으니 다시 돌아가긴 싫었다. 오타 낸 사람 입장에서도 브라우저 에러 화면만 보고 끝난다. 블로그나 루트 도메인으로 301 리다이렉트하는 것도 생각했다. 근데 무한히 많은 주소가 전부 정상 주소의 별칭처럼 동작하게 되고, 서브도메인 오타를 낸 사람이 원래 가려던 곳이 블로그라는 보장도 없다. coin을 치려다 틀렸을 수도 있다. 그래서 없는 주소라는 걸 분명히 알려주고, 대신 실제로 있는 서비스 목록을 보여주는 안내 페이지 로 정했다. 어디로 가려 했는지는 본인이 제일 잘 아니까 고르게 하는 게 맞다고 봤다. 기본 vhost를 일부러 만든다 Apache 쪽 작업은 생각보다 단순하다. 기본 vhost가 파일 이름 순서로 정해진다면, 이름이 맨 앞에 오는 vhost를 하나 만들면 된다. # /etc/apache2/sites-available/000-catchall.conf # 매칭되는 vhost 가 없는 모든 요청(오타 서브도메인, 직접 IP 접속 등)을 받는 기본 vhost. # sites-enabled 안에서 알파벳 순으로 가장 먼저 로드되어야 default vhost 가 되므로 # 파일 이름의 000- 접두사를 바꾸지 말 것. VirtualHost *:80 ServerName catchall.jaeyonging.com ServerAdmin admin@jaeyonging.com DocumentRoot /var/www/catchall Directory /var/www/catchall Require all granted Options -Indexes AllowOverride None /Directory ProxyRequests Off # "/" 는 안내 페이지, 그 외 경로는 같은 페이지를 404 상태로 응답 DirectoryIndex index.html ErrorDocument 404 /index.html # 무한한 오타 서브도메인이 검색엔진에 색인되지 않도록 Header always set X-Robots-Tag "noindex, nofollow" ErrorLog ${APACHE_LOG_DIR}/catchall_error.log CustomLog ${APACHE_LOG_DIR}/catchall_access.log combined /VirtualHost 파일 이름 앞의 000- 이 이 설정의 전부라고 봐도 된다. 숫자는 알파벳보다 앞에 정렬되니까 이 파일이 항상 먼저 읽힌다. 나중에 내가 이걸 보고 이름을 예쁘게 바꿀 수도 있어서 주석으로 못을 박아뒀다. ServerName catchall.jaeyonging.com 은 실제로 쓰는 주소가 아니다. ServerName을 비워두면 서버 전역 설정의 ServerName을 물려받는데, 내 서버는 그게 jaeyonging.com이라 루트 도메인 vhost랑 이름이 겹쳐 보인다. 헷갈리지 않게 안 쓰는 이름을 따로 줬다. 다른 vhost들은 전부 ProxyPass 로 Node 서버에 넘기는데, 이건 Apache가 디스크에 있는 HTML 파일을 바로 준다. 일부러 그렇게 했다. 안내 화면이 Node 프로세스에 기대면 서버가 아플 때 안내 화면도 같이 죽는다. 지금은 PM2 프로세스가 전부 내려가 있어도 이 화면은 뜬다. ProxyRequests Off 는 이 vhost가 포워드 프록시로 쓰이지 않게 막는 설정이다. 기본값도 Off라 안 적어도 되지만 명시해뒀다. 실제로 로그를 보면 GET http://azenv.net/ 같은 요청이 들어온다. 내 서버를 남의 사이트로 가는 중간 다리로 쓸 수 있는지 떠보는 요청이다. 여기서 한 번 헷갈린 것, 파일 순서 순서를 확인하려고 ls 를 쳤는데 예상과 다르게 나왔다. $ ls /etc/apache2/sites-enabled | head -4 000-catchall.conf 000-catchall-ssl.conf blog.jaeyonging.com.conf blog-ssl.conf $ LC_ALL=C ls /etc/apache2/sites-enabled | head -4 000-catchall-ssl.conf 000-catchall.conf blog-ssl.conf blog.jaeyonging.com.conf ls 는 로케일 규칙으로 정렬해서 하이픈이나 점 같은 구두점을 거의 무시하고 비교한다. 근데 Apache는 바이트 값 그대로 정렬한다. 하이픈이 점보다 앞이라 실제로는 blog-ssl.conf 가 blog.jaeyonging.com.conf 보다 먼저 읽힌다. Apache가 보는 순서로 보려면 LC_ALL=C 를 붙여야 한다. 이번엔 80 포트 파일과 443 포트 파일이 서로 안 겹쳐서 결과는 같았지만, 이름 순서에 기대는 설정을 만들 거라면 이 차이는 알고 있어야 한다. 그냥 ls 만 믿고 순서를 맞췄다가 엉뚱한 vhost가 기본값이 되는 일이 충분히 생길 수 있다. HTTPS는 와일드카드 인증서로 처음엔 80 포트 하나만 받으면 되지 않나 생각했다. 근데 루트 도메인 설정에 HSTS가 includeSubDomains 로 걸려 있다. jaeyonging.com에 한 번이라도 들어와 본 브라우저는 이후 모든 서브도메인을 https로만 연다. 그런 브라우저한테 http 안내 페이지는 아예 안 보인다. 그래서 443용 catchall도 같이 만들었다. # /etc/apache2/sites-available/000-catchall-ssl.conf (일부) VirtualHost *:443 ServerName catchall.jaeyonging.com SSLEngine on SSLCertificateFile .../_wildcard_.jaeyonging.com_20260201F68CA.all.crt.pem SSLCertificateKeyFile .../_wildcard_.jaeyonging.com_20260201F68CA.key.pem DocumentRoot /var/www/catchall DirectoryIndex index.html ErrorDocument 404 /index.html Header always set X-Robots-Tag "noindex, nofollow" /VirtualHost 인증서는 다른 서비스들이 쓰는 *.jaeyonging.com 와일드카드를 그대로 쓴다. asdfas든 helo든 한 단계짜리 서브도메인이면 전부 인증서가 맞는다. 근데 와일드카드는 딱 한 단계만 덮는다. 점이 하나 더 들어가면 인증서가 안 맞아서 안내 페이지까지 가지도 못한다. $ curl -sS https://a.b.jaeyonging.com/ curl: (60) SSL: no alternative certificate subject name matches target host name 'a.b.jaeyonging.com' 이건 인증서를 따로 받지 않는 이상 방법이 없어서 그냥 두기로 했다. 두 단계짜리 오타는 거의 안 나기도 하고. 대신 이걸 확인하다가 다른 걸 하나 찾았다. 블로그 설정에 ServerAlias www.blog.jaeyonging.com 이 들어 있는데, 이것도 두 단계라 같은 이유로 https 접속이 안 된다. 몇 달 동안 아무도 들어올 수 없는 별칭을 달고 있었던 거다. 처음 설정할 때 다른 vhost를 복사해서 만들다가 딸려온 줄인데, 다음에 Apache를 손볼 때 같이 지울 생각이다. 상태 코드는 몇으로 줄 건가 이 부분을 제일 오래 고민했다. 없는 주소니까 전부 404를 주는 게 맞아 보인다. 근데 서브도메인 자체는 DNS에도 있고 서버도 정상적으로 응답한다. 이 서브도메인의 루트는 "없는 페이지"라기보다 "안내 페이지"에 가깝다고 봤다. 그래서 루트( / )는 200으로 안내 페이지를 주고, 그 아래에 아무 경로나 붙은 요청은 404로 주되 본문은 같은 안내 페이지를 보여주게 했다. ErrorDocument 404 /index.html 한 줄이 그 역할을 한다. 존재하지 않는 파일을 찾으면 Apache가 404 상태 코드는 그대로 두고 본문만 index.html로 바꿔서 내보낸다. 물론 루트를 200으로 주면 검색엔진 입장에서는 soft 404 비슷한 게 된다. 와일드카드 DNS라 주소가 무한히 만들어질 수 있으니 색인이 되기 시작하면 정리가 안 된다. 그래서 X-Robots-Tag: noindex, nofollow 를 응답 헤더로 붙였다. HTML 안에 robots 메타 태그도 넣었지만, 헤더로 걸면 favicon 같은 HTML이 아닌 응답까지 같이 막힌다. Header always 로 쓴 것도 이유가 있다. always 를 빼면 404 같은 에러 응답에는 헤더가 안 붙는다. $ curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" https://asdfas.jaeyonging.com/ 200 https://asdfas.jaeyonging.com/ $ curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" https://asdfas.jaeyonging.com/abc 404 https://asdfas.jaeyonging.com/abc $ curl -sI https://asdfas.jaeyonging.com/ | grep -iE '^(HTTP|x-robots|content-type)' HTTP/2 200 x-robots-tag: noindex, nofollow content-type: text/html 화면은 정적 HTML 한 장이다 구성은 단순하다. 404 안내 문구, 지금 들어온 주소, 그리고 실제로 운영 중인 서비스 카드들. 들어온 주소를 보여주는 건 사소해 보여도 꽤 쓸모가 있다. 오타를 낸 사람은 대개 자기가 뭘 쳤는지 모르고 있어서, 주소를 눈앞에 보여주면 어디가 틀렸는지 바로 안다. 주소는 서버에서 끼워 넣지 않고 JS로 넣는다. 정적 파일이라 서버 쪽 템플릿이 없기 때문이다. mod_include를 켜면 서버에서도 넣을 수 있지만, 이 화면 하나 때문에 모듈을 하나 더 켜고 싶진 않았다. div class="host" id="host" jaeyonging.com /div script (function () { var el = document.getElementById('host'); if (el location.hostname) el.textContent = location.hostname; })(); /script innerHTML 이 아니라 textContent 로 넣은 데도 이유가 있다. 호스트명은 요청하는 쪽이 마음대로 정하는 값이다. 실제로 꺾쇠 같은 문자가 호스트명에 들어올 일은 거의 없지만, 사용자가 정하는 값을 HTML로 해석하게 두는 건 습관적으로 피한다. JS가 꺼져 있으면 기본값인 jaeyonging.com이 그대로 보인다. 어떤 서비스를 카드로 보여줄지도 골랐다. 서버 상태판인 monitor와 포트폴리오 비공개 미리보기인 preview는 일부러 뺐다. 남한테 보여줄 목적이 아닌 서비스를 오타 화면에 띄우면 오히려 거기 그런 게 있다고 광고하는 셈이 된다. 카드는 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)) 로 깔아서 화면 폭에 맞춰 알아서 줄이 바뀐다. 폰에서는 한 줄에 하나씩 나온다. 오타는 대부분 폰에서 나니까 모바일 화면을 더 신경 썼다. 2주 동안 누가 들어왔나 catchall은 로그 파일을 따로 뺐다. 보관된 2주치를 세어보니, 집 공유기 주소에서 온 요청을 빼고 6,090건, IP로는 1,517개였다. 응답 코드별 (catchall_access.log + catchall_ssl_access.log, 2주) 403 2,899 ModSecurity 차단 404 1,132 200 1,022 400 365 405 48 절반 가까이가 403이다. ModSecurity가 막은 요청인데, 경로를 보면 /.env , /.aws/credentials , /.git/config 같은 것들이다. 설정 파일이나 키를 찾아다니는 스캐너들이다. 와일드카드 DNS라 스캐너가 아무 서브도메인이나 만들어서 두드리는데, 그런 요청이 전부 여기로 모인다. 그럼 진짜로 오타를 낸 사람은 얼마나 될까 궁금했는데, 이건 로그만으로는 가려내기 어려웠다. 브라우저 UA를 단 요청이 꽤 많지만 스캐너도 브라우저인 척을 하니 UA로 사람 수를 셀 수는 없다. 처음엔 오타 낸 사람을 안내하려고 만든 건데, 막상 돌려보니 이상한 요청들을 블로그 로그에서 떼어낸 효과가 더 컸다. 전에는 이게 전부 블로그 로그에 섞여 있어서, 블로그 로그를 볼 때마다 스캐너 요청부터 걸러내야 했다. 아직 아쉬운 것 서비스 목록이 HTML에 직접 박혀 있다. 서비스를 새로 올리면 손으로 카드를 추가해야 한다. 실제로 9월에 ieum이라는 서비스를 올렸는데, 이 글을 쓰면서 보니 catchall 목록에는 아직 없다. sites-enabled 를 읽어서 목록을 자동으로 만드는 것도 생각해봤다. 근데 위에서 말한 것처럼 일부러 숨기는 서비스가 있어서, 결국 보여줄 서비스를 따로 적어둔 표가 필요하다. 서비스가 열 개 남짓인 지금은 그냥 손으로 고치는 게 낫다고 봤다. 그리고 기본 vhost가 파일 이름 하나에 걸려 있다는 게 조금 불안하다. 누가 000- 보다 앞에 오는 이름으로 파일을 하나 만들면 조용히 기본값이 바뀐다. 주석으로 적어두긴 했지만, 그 누가 몇 달 뒤의 나일 가능성이 제일 높다. 와일드카드 DNS로 서브도메인을 열어두고 쓰고 있다면 아무 주소나 하나 쳐보길 추천한다. 거기서 뭐가 뜨는지 모르고 있었다면, 그게 지금 내 서버의 기본 vhost다.