시작은 침입이 아니라 소음이었다.

내 nginx는 에 연결되어 있어서 이상이 있으면 Discord로 날아온다. 이렇게 생겼는데, 그 시점의 Top IP와 Top Path까지 함께 붙여준다.

진단 정보는 넘칠 만큼 친절하다. 문제는 하루에 수백에서 수천 번 울린다는 것이고, 게다가 그 대부분은 내 서비스에 문제가 생긴 게 아니었다. 스캐너가 /wp-login.php, /.env, 온갖 .php 엔드포인트를 두드려서 생기는 4xx다. 내 사이트에는 PHP가 하나도 없지만 저쪽은 그걸 모르고 신경도 쓰지 않는다. 그냥 계속 두드린다.

(위 사진은 오히려 드물게 "진짜 뭔가 있었던" 경우로, /api/spotify/audio-features가 5분 동안 9968번 맞았다. 그건 내 코드가 루프를 돌고 있었다. IP는 가렸다. 저건 내 출구 주소이기 때문이다.)

당시 이 머신에는 이미 fail2ban이 있었고, 숫자도 훌륭했다.

4257
누적 로그인 실패
fail2ban 집계
614
누적 차단한 IP
0
현재 차단 중
차단 시간이 짧아 만료되면 풀린다

614개나 차단했다고 하면 쓸모 있어 보인다. 그런데 그 알림 줄을 보다가 한 가지를 깨달았다. 저쪽은 계속 IP를 바꾸고 있다. 하나를 막아도 다음은 다른 회선으로 온다. 이런 식으로 계속 차단해서 끝이 있기는 한 걸까.

이 글은 그 뒤 CrowdSec으로 갈아탄 전 과정이다. 왜 이 상황에서는 fail2ban보다 잘 맞는지, 구축하면서 밟은 세 가지 함정, 이 머신이 6일 동안 실제로 무엇을 막았는지, 그리고 내가 나 자신을 문밖에 가둔 세 번(세 번째가 가장 멍청하고 가장 쓸 가치가 있다). 마지막은 직접 만든 Discord 해제 bot 이야기다. 자기 방어에 갇혔을 때 SSH야말로 닿지 않는 그것이기 때문이다.

이 머신은 어떻게 생겼나#

뒤에 나올 함정도 자기 차단도 구조에 달려 있으니, 먼저 환경을 분명히 해두겠다.

뒤에 영향을 주는 몇 가지.

  • 전부 한 대의 물리 머신에서 돌아간다. 집 고정회선에 고정 IP, 라우터가 80/443을 전달해 준다. 클라우드 로드밸런서도 없고, 문제가 생겼을 때 대신 버텨줄 두 번째 장비도 없다.
  • 차단은 nftables에서 일어난다. 즉 nginx보다 앞이다. 차단된 요청은 access log에조차 남지 않고, 이쪽에서 보이는 건 연결 거부가 아니라 침묵하는 타임아웃이다. 이 위치 관계가 뒤에 나오는 모든 진단의 토대가 된다.
  • CrowdSec 엔진과 bouncer는 별개다. 엔진이 nginx의 log를 읽고 판단하고, bouncer가 방화벽에서 실행한다. 그림의 점선 두 개가 그 루프다.
  • nginx 뒤에는 십여 개의 서브도메인이 걸려 있다. 블로그, NAS, 모니터링, MQTT, 자잘한 도구들. 그중 어느 하나의 속도 제한이나 오류도 같은 access log로 흘러들고, 결국 CrowdSec으로 흘러든다.

본문과 관련된 게 하나 더 있다. 내 메인 도메인은 Cloudflare 그레이 클라우드(DNS만 하고 트래픽은 집으로 직결)를 쓰지만, 다른 운영자가 자기 서브도메인 하나를 오렌지 클라우드로 이쪽에 가리키고 있다. 그 경로의 출발지 IP는 Cloudflare 노드가 된다. 이 일은 교차 모니터링 글에서 한 번 나를 물었고, 자세한 건 그쪽에 썼다.

왜 fail2ban이 아닌가#

fail2ban은 망가지지 않았다. 명세대로 자기 일을 제대로 한다. 문제는 그 모델에 있다. 자기 log를 읽고, 자기 실패 횟수를 쌓고, 자기가 본 IP를 차단한다.

IP를 돌려가며 오는 상대에게 이건 두더지 잡기다. 새 IP는 모두 이쪽에 초면이라 0부터 임계값까지 쌓여야 차단되는데, 그때쯤이면 이미 한 바퀴 훑고 지나갔다.

CrowdSec은 전제를 바꿨다. 남이 이미 마주친 나쁜 IP를 그대로 가져다 쓴다.

fail2banCrowdSec
판단 근거로컬 log뿐로컬 log 에 더해 전 세계 커뮤니티 차단 목록
본 적 없는 IP0부터 쌓으며 한 바퀴는 그냥 통과누군가 신고했으면 즉시 차단
차단 실행직접 iptables를 고침bouncer에 맡김(nftables / nginx / Cloudflare로 교체 가능)
규칙 출처직접 쓰는 정규식Hub의 커뮤니티 규칙. CVE 시나리오도 많다

세 번째 줄의 "bouncer에 맡김"은 실제 아키텍처 차이다. CrowdSec은 탐지실행을 분리했다. 엔진은 누구를 막을지 결정만 하고, 방화벽을 건드리는 건 별도의 bouncer다. 이 분리가 뒤에서 중요해진다.

나에게 결정적이었던 게 하나 더 있다. 자체 호스팅이라는 점이다. 당시 다른 선택지는 사이트를 Cloudflare 뒤에 두고 흡수시키는 것이었지만, 그건 트래픽을 남의 프록시에 맡기는 것과 같다. CrowdSec은 로컬에서 log를 읽고 로컬 방화벽에서 실행하므로 기존 구조를 전혀 건드리지 않는다.

NOTE

커뮤니티 차단 목록은 기본으로 켜져 있다 설치할 때 Registering to CAPI(Central API)가 자동으로 실행되므로, 그 공유 차단 목록은 추가 설정 없이 곧바로 지켜주기 시작한다. cscli console enroll은 선택 사항이고 대시보드와 더 큰 구독 목록을 준다.

세 가지 함정, 뿌리는 하나#

CrowdSec 설치는 두 줄이다.

bash
curl -s https://install.crowdsec.net | sudo sh
sudo apt install -y crowdsec
sudo apt install -y crowdsec-firewall-bouncer-nftables   # Ubuntu 24.04는 기본이 nftables

그리고 하룻밤을 통째로 날렸다. 게다가 세 함정은 도미노 한 장이 넘어진 연쇄였다.

apt가 ESM에서 아주 오래된 버전을 가져왔다

공식 스크립트는 분명히 packagecloud의 repo를 추가해 준다. 그런데 apt install crowdsec이 실제로 내려받은 곳은 esm.ubuntu.com이었다. 에는 동결된 1.4.6이 있고, packagecloud 쪽은 1.7.x다.

설치 시점에는 어떤 오류도 나지 않는다. cscli version을 쳐야 비로소 버전이 틀렸다는 걸 알게 된다.

솔직히 이 설계는 지금도 짜증스럽다. ESM의 취지는 서드파티 패키지의 보안 유지보수를 연장하는 것이지만, 버전을 어느 시점에 동결한 채로 방금 추가한 공식 repo보다 앞에 선다. 결과는 "최신 버전을 설치한 줄 안다"이다. 이 일을 겪고 진지하게 생각해 봤다. 그저 깨끗한 서버 하나가 필요할 뿐이라면 Debian이 Ubuntu LTS보다 이런 놀라움이 훨씬 적을 것이다.

메이저 버전을 건너뛴 업그레이드에서 hub 경로가 옮겨졌다

버전이 낡았다는 걸 알면 당연히 올린다. 그런데 1.4.6과 1.7.8 사이에 hub 데이터의 위치가 바뀌었다.

console
구버전  /var/lib/crowdsec/hub/...
신버전  /etc/crowdsec/hub/...

구버전이 남긴 수많은 symlink는 전부 존재하지 않는 경로를 가리켜 끊어진 링크가 되어 있었다. 설치할 때 흐르는 Ignoring file ... no such file or directory 줄들이 바로 이것인데, 수십 줄의 출력에 묻혀 잡음으로 흘려보내기 쉽다.

parser가 로드되지 않아 아무것도 탐지하지 못했다

끊어진 symlink는 nginx-logs 같은 parser가 애초에 로드되지 않았다는 뜻이다. 증상은 cscli metrics 상으로는 log를 읽는 것처럼 보이는데 파싱률이 9%밖에 안 되는 것이다.

console
Source                          Lines read   Lines parsed
file:/var/log/nginx/access.log  41           3

파일은 읽지만 내용을 이해하지 못한다. 이해하지 못하면 시나리오가 발동하지 않고, 시나리오가 발동하지 않으면 아무도 차단되지 않는다. 겉으로 서비스는 초록색이고 실제로는 완전한 헛돌기다.

고치는 법은 끊어진 링크를 정리하고 규칙 세트를 다시 설치하는 것이다.

bash
sudo find /etc/crowdsec -xtype l -delete     # "대상이 없는" symlink만 지운다
sudo cscli hub update
sudo cscli collections install \
  crowdsecurity/nginx crowdsecurity/sshd crowdsecurity/linux \
  crowdsecurity/whitelist-good-actors --force
sudo systemctl reload crowdsec

TIP

교훈: packagecloud를 pin해서 1.7.x를 바로 설치할 것 이 세 함정은 하나의 인과 사슬이고 근원은 단 하나, 첫걸음에서 ESM의 옛 버전을 설치한 것이다. 설치 직후 cscli version으로 버전 번호를 확인하면 뒤의 전부를 건너뛸 수 있다.

오독하기 쉬운 진단 포인트 두 가지#

cscli metrics의 숫자는 누적값이다. 파싱률을 고쳤는데도 나아지지 않는다고 여긴 적이 있었는데, 새로 파싱된 줄이 과거 실패의 더미에 희석되고 있었을 뿐이다. reload로는 카운터가 지워지지 않고 restart라야 지워진다. "고친 뒤"의 진짜 비율을 보려면 재시작하고 다시 쌓아야 한다.

cscli explain을 쓰면 한눈에 판가름 난다. log 한 줄이 실제로 지나간 parser 사슬을 출력해 주므로 어느 단계에서 실패했는지 곧바로 보인다.

bash
sudo cscli explain --file /var/log/nginx/access.log --type nginx | tail -25

고쳐졌다면 parser failure만 늘어선 게 아니라 crowdsecurity/nginx-logs가 초록으로 통과해 시나리오로 흘러가는 게 보여야 한다.

SSH 수집원은 하나만 남긴다#

내가 직접 조정한 게 하나 더 있다. SSH 이벤트는 /var/log/auth.log에서도 journalctl에서도 읽을 수 있는데, 둘은 같은 이벤트 묶음의 두 입구다. 둘 다 수집하면 로그인 실패가 두 번씩 세어져, 설정한 임계값보다 일찍 시나리오가 발동하고 alert 건수도 부풀려진다.

실측해 보니 양쪽 모두 261건을 파싱했지만, auth.log는 거기 닿기까지 10.13k줄을 읽어야 했고 journalctl은 330줄이면 됐다. 그래서 내 acquis.yaml은 journalctl만 남긴다.

yaml
source: journalctl
journalctl_filter:
  - "_SYSTEMD_UNIT=ssh.service"
labels:
  type: syslog

6일에 1619건#

이게 이 머신의 최근 6일 실제 수치다(cscli alerts list 전수 집계).

1619
탐지한 공격
6일, 하루 평균 270
25.22kIP
방화벽에서 차단 중
61.29k
버린 패킷
총 3.52 MB

공격 유형별로 나누면 이렇다.

시나리오건수무엇을 노리는가
ssh-time-based-bf978SSH 저속 무차별 대입
http-probing144닥치는 대로 엔드포인트 떠보기
ssh-time-based-bf_user-enum121사용자명 추측
http-sensitive-files44.env, .git 같은 파일 찾기
http-bad-user-agent39알려진 스캐너 UA
http-wordpress-scan36WordPress 구멍 찾기
http-admin-interface-probing36관리자 로그인 페이지 찾기
http-cve-2021-4177333Apache 경로 traversal

나머지 롱테일은 CVE 탐색의 행렬이다. CVE-2017-9841(PHPUnit RCE), http-cve-2021-42013, CVE-2022-41082(Exchange), thinkphp-cve-2018-20062, netgear_rce, fortinet-cve-2018-13379, jira_cve-2021-26086. 하나도 쓰지 않는데 그래도 매일 두드리러 온다.

공격은 "나쁜 사람의 컴퓨터"에서 오지 않는다#

출발지 AS의 분포야말로 가장 흥미로운 열이다.

출발지건수
MICROSOFT-CORP-MSN-AS-BLOCK199
Techoff Srv Limited191
DIGITALOCEAN-ASN171
Hangzhou Alibaba Advertising165
Hetzner Online GmbH161
WEB MASTER COLOMBIA SAS153
Sai gon Postel Corporation137

상위 일곱은 거의 전부 클라우드 데이터센터다. Azure, DigitalOcean, 알리바바 클라우드, Hetzner. 스캐너는 시간당 과금되는 VM 위에서 돈다. 한 대를 막아도 다른 한 대를 띄우는 건 몇 초면 되고, IP도 달라진다.

이게 처음의 질문에 대한 데이터 쪽의 답이 된다. IP를 차단하는 길에서는 상대의 비용이 이쪽보다 훨씬 싸다. 그리고 바로 그 지점이 커뮤니티 차단 목록의 가치이기도 하다. 새로 띄운 그 VM은 이쪽에 오기 전에 대개 다른 누군가를 이미 때렸기 때문이다.

IMPORTANT

커뮤니티 차단 목록이 막는 양은 내가 직접 잡는 양의 삼천 배 cscli metrics의 bouncer 항목은 두 출처를 분명히 나눈다.

console
Origin                       active_decisions
CAPI (community blocklist)   25.21k
crowdsec (security engine)        8

내 엔진이 그 시점에 잡은 건 8개. 커뮤니티 차단 목록은 동시에 25210개를 공급하고 있었다. 누적 차단 결정을 봐도 http:exploit 17249, ssh:bruteforce 6632, http:scan 2894가 전부 CAPI에서 왔다. 다시 말해 이 도구의 가치 대부분은 설치를 마친 그 순간에 발효된다. 자기 이력이 쌓이길 기다릴 필요가 없다.

차단되는 게 전부 나쁜 놈은 아니다#

분명히 해둘 세부가 하나 있다. 어느 차단 목록에 crowdsecurity/http-bad-user-agent로 막힌 167.94.146.48이 있었다. 찾아보니 Censys, 인터넷 전체를 조사하는 연구 기관의 것이었다. 같은 부류로 Shodan이 있다. 이들은 이쪽을 공격하려는 게 아니라 전 세계의 공개 서비스를 목록화하고 있다.

막을지 말지는 각자의 판단이지만, 무엇을 막고 있는지는 알아두는 게 낫다. 덧붙이자면 선의의 크롤러는 걱정하지 않아도 된다. whitelist-good-actors collection이 기본으로 설치되어 Google, Bing, 각 SEO 크롤러가 모두 화이트리스트에 있고, 게다가 User-Agent만 보는 게 아니라 하므로 사칭할 수 없다.

나 자신을 세 번 가뒀다#

여기가 전체에서 가장 기억할 가치가 있는 부분이다. 자동으로 차단하는 시스템은 머지않아 자기 자신을 차단한다.

첫 번째: SSH 비밀번호 로그인을 끈 직후

SSH를 키 인증으로 바꾸고 비밀번호를 껐다. 그런데 LAN의 개발 머신이 아직 옛 방식으로 재시도하며 계속 실패하고 있어서, ssh-bf 시나리오가 무차별 대입으로 판정하고 bouncer가 그 내부 IP를 통째로 drop했다.

증상이 묘했다. LAN에서는 전혀 붙지 않는데 밖에서 돌아 들어오면 멀쩡하다. 외부 경로는 다른 출발지 IP이기 때문이다.

두 번째: 앱 버그가 연결 폭풍을 불렀다

파일 업로드 기능을 테스트하고 있었는데 그 handler에 버그가 있었다. 업로드하면 머신 전체가 얼어붙는다. 얼어붙으니 내 컴퓨터가 SSH와 HTTPS를 미친 듯이 재연결하기 시작했다. CrowdSec이 본 건 "어떤 IP가 짧은 시간에 두드려대고 있다"는 광경이었고, 공격으로 판정해 차단했다.

그때의 그림은 이랬다. 휴대폰 SSH는 들어가지는데 컴퓨터는 아무 데도 못 붙고 사이트도 전부 안 열린다. 휴대폰은 모바일 회선이라 다른 IP였다.

세 번째: 내 속도 제한이 내 차단 장치에 먹이를 줬다

같은 날 조금 뒤에 또 당했다. 이 메커니즘은 따로 설명할 가치가 있다. cscli decisions list를 보면:

console
Ip:<내 출구IP>  crowdsecurity/nginx-req-limit-exceeded  ban  6 events

nginx-req-limit-exceeded라는 시나리오가 하는 일은 nginx가 429를 돌려주는 걸 보고 그 IP가 트래픽을 퍼붓는다고 판정하는 것이다. 그리고 그 429는 내가 직접 설정한 nginx 속도 제한이 낸 것이다.

그래서 루프는 이렇게 된다.

내가 넣은 방어 둘이 이어져서 나를 물었다.

세 가지 신호로 곧바로 판별할 수 있다#

CrowdSec에 막힌 것과 서비스가 죽은 것은 증상이 실제로 다르다.

신호의미
connection refused가 아니라 i/o timeout패킷이 조용히 버려지고 있다. 방화벽 DROP의 특징이고, 서비스가 죽었다면 refused가 된다
휴대폰은 정상, 컴퓨터만 전멸출발지 IP가 다르므로 문제는 서버가 아니라 "당신의 그 회선"에 있다
이미지나 CSS조차 못 불러온다IP가 통째로 막힌 것이다. 특정 API가 망가진 것과는 다르다

셋이 동시에 성립하면 거의 곧장 cscli decisions list를 열고 자기를 찾아도 된다.

고치는 법은 두 층이다#

LAN에는 whitelist parser를 쓴다. s02-enrich에 parser를 하나 두어 RFC1918 전체가 영원히 차단되지 않게 한다.

yaml
# /etc/crowdsec/parsers/s02-enrich/lan-whitelist.yaml
name: koimsurai/lan-whitelist
description: "Never ban LAN / RFC1918"
whitelist:
  reason: "private LAN"
  cidr:
    - "192.168.0.0/16"
    - "10.0.0.0/8"
    - "172.16.0.0/12"

이 규칙은 지금까지 16037번 통과시켰다. 없었으면 얼마나 성가셨을지 짐작이 가는 숫자다.

공개 출구 IP에는 allowlist를 쓴다. LAN 화이트리스트는 "밖에 나가 있을 때"를 구해주지 못한다. 그때의 출발지는 공개 IP이기 때문이다.

bash
sudo cscli allowlists create home-dev -d "내 개발 머신 IP"
sudo cscli allowlists add home-dev <당신의 출구IP>
sudo cscli allowlists inspect home-dev

WARNING

시나리오를 하나씩 느슨하게 하느니 allowlist가 낫다 nginx-req-limit-exceeded모든 nginx 429에 발동한다. 어느 사이트의 어느 제한 규칙이 냈든 상관없다. 제한을 하나씩 조정하는 건 두더지 잡기이고, allowlist는 내 IP 전부를 한 번에 면역시켜 루프를 끊는다.

대가는 알아둘 것. allowlist에 오른 IP에는 애초에 차단 결정이 만들어지지 않는다. 그러니 정말 신뢰하는 출발지만 올릴 것. 그리고 가정용 유동 IP는 재접속할 때마다 바뀌므로 올려도 소용없다.

Discord로 나를 꺼낸다#

세 번 모두에 공통된 성가심은 이것이다. 차단을 풀려면 먼저 들어가야 한다. 그리고 내가 막혔을 때 바로 그 안 되는 것이 SSH다.

당시의 대안은 휴대폰이었다. 로 붙는다. 평소에는 꽤 좋아하고 필요한 건 다 있다. 그런데 길에서, 한 손으로, 터치 키보드로 sudo cscli decisions delete --ip ... 같은 명령을 치고, 게다가 그 전에 표 전체에서 어느 IP가 나인지 가려내야 한다. 경험이라고 부르기는 어렵다.

사태를 더 성가시게 하는 게 하나 더 있다. CrowdSec은 누구를 차단했는지 기본적으로 알려주지 않는다. profiles.yamlnotifications 줄은 전부 주석 처리되어 있어서, slack / http / email 플러그인을 직접 연결하지 않으면 아무것도 오지 않는다. 그래서 "내 시스템에 막혔다"는 사실이 먼저 알려지는 일은 없다. 안 붙는다는 걸 알아차리고, 거기서부터 추측이 시작된다.

yaml
# /etc/crowdsec/profiles.yaml —— 기본은 이렇다. 알림은 전부 주석
decisions:
 - type: ban
   duration: 4h
# notifications:
#   - slack_default
#   - http_default

그래서 기존 Discord bot에 명령 두 개를 더해 "찾기"와 "풀기"를 함께 해결하기로 했다. 그런데 여기에 구조적 문제가 있다. bot은 컨테이너 안에서 돌고 CrowdSec은 호스트 서비스라서, 컨테이너에서 호스트의 cscli에 닿을 수 없다.

docker socket이나 호스트 root 권한을 컨테이너에 넘기면 해결되지만, 그건 바깥으로 연결되는 bot에게 머신 전체의 통제권을 주는 것과 같다. 그래서 파일 큐로 바꿨다.

컨테이너가 할 수 있는 건 요청 파일을 큐에 놓는 것뿐이다. 실제로 cscli를 실행하는 건 호스트에서 root로 상주하는 watcher이고, 요청을 읽고 검증하고 실행하고 결과를 되돌려 쓴다. 설령 bot이 뚫려도 공격자가 할 수 있는 건 요청 파일을 놓는 것뿐이고, 동작도 제한되어 있어 CrowdSec의 통제권에는 닿지 못한다.

watcher의 핵심 몇 줄:

python
ALLOWLIST = "parole"          # 해제한 뒤 이 allowlist에도 넣어둔다
ALLOWLIST_TTL = "4h"          # 4시간만. 영구 통행증은 아니다

def valid_ip(s):
    try:
        ipaddress.ip_address(s)   # command injection 차단
        return True
    except ValueError:
        return False

if action == "unblock":
    ip = str(req.get("ip", ""))
    if not valid_ip(ip):
        return {"ok": False, "output": f"invalid ip: {ip}"}
    rc, out = run(["decisions", "delete", "--ip", ip])
    ensure_allowlist()
    run(["allowlists", "add", ALLOWLIST, ip, "-e", ALLOWLIST_TTL])

설계상의 세부 세 가지.

IP는 반드시 검증하고 명령에 넣는다

이 문자열은 subprocess로 넘어간다. 리스트 형식이라 셸을 거치지는 않지만, 검증하지 않은 필드는 언젠가 사고가 된다. ipaddress.ip_address() 한 줄로 막힌다.

해제 뒤 4시간의 parole을 준다

차단 결정만 지우면 그것을 일으킨 행동은 대개 계속되고 있어서(예컨대 아직 새로고침을 연타하고 있다) 몇 초 뒤 다시 차단된다. 그래서 해제하는 김에 parole이라는 allowlist에 IP를 넣는다. 다만 4시간만이고 영구히 통과시키지는 않는다.

쓸 수 있는 건 운영자 본인뿐

cog는 interaction.user.id == OWNER_ID를 확인하고, 게다가 OWNER_ID가 설정되지 않았을 때의 기본은 모두 거부이지 모두 허용이 아니다. 명령 응답은 전부 ephemeral이라 차단 목록이 채널에 남지도 않는다.

만들고 나니 /crowdsec_list가 현재 차단 목록을 그대로 휴대폰에 펼쳐준다. 각 줄에 IP, 발동한 시나리오, 남은 시간이 들어 있다.

자기 줄을 찾았으면 /crowdsec_unblock에 IP를 넣으면 풀린다. 응답이 parole을 몇 시간 줬는지도 알려준다.

이 목록의 IP들이 겸사겸사 보여주는 게 있다. http-cve-2021-41773, http-wordpress-scan, http-sensitive-files는 모두 앞의 통계에 나온 얼굴들이고, AS를 조회하면 또 Google Cloud와 Azure다. 예시로 가져온 이 한 무리조차 여전히 클라우드 데이터센터에서 도는 스캐너인 것이다.

그리고 전체를 성립시키는 결정적 요소는 실로 단순하다. bot 쪽에서 Discord로 연결하러 나간다. IP가 방화벽에서 DROP되어 막히는 건 "들어오는" 방향이고, bot이 이미 맺어둔 바깥 방향 연결에는 전혀 영향이 없다. 그래서 갇혀 있는 와중에도 이쪽의 명령은 제대로 닿는다.

자기 머신에 세운 web 관리 화면으로는 이게 안 된다. 쓰려면 붙어야 하는데, 바로 그 붙는 것이 안 되기 때문이다. 휴대폰 SSH는 차단을 우회하긴 하지만(모바일 회선은 다른 IP다), 그래도 휴대폰으로 온전한 명령을 쳐야 하는 건 남는다.

처음으로 돌아가서: Discord는 조용해졌나#

조용해졌다. 다만 이유는 애초에 하려던 것과 다르다.

CrowdSec을 설치하는 것과 병행해서, netdata 경고를 잠재우는 설정 파일도 준비해 뒀었다. 4xx 그룹을 silent로 돌리고 5xx와 "느려짐"만 남길 생각이었다. 다 써놨고 명령도 준비되어 있었다.

그런데 방금 들여다봤더니:

console
$ ls /etc/netdata/health.d/
(비어 있음)

$ ls ~/Server/netdata-web-noise-silence.conf
-rw-rw-r-- 1 timo9378 timo9378 2922  netdata-web-noise-silence.conf

그 무음 파일은 한 번도 배치된 적이 없었다. 아직 홈 디렉터리에 굴러다니고 있다. 그리고 확실히 4xx 경고에 시달린 지 오래됐다.

이유는 4xx가 알아서 사라졌기 때문이다. 지금 같은 access log에서 6038줄을 뽑아 세면 이렇다.

46.1%
2xx
4.0%
4xx
경고 임계값보다 한참 아래
0.0%
5xx

스캐너는 nginx에 닿기 전에 bouncer가 방화벽 층에서 drop하므로 그 404는 log에 한 줄도 남지 않는다. 4xx 비율은 자연히 임계값 아래에 머물고 경고는 발동하지 않는다.

이 결과는 원래 하려던 것보다 훨씬 낫다. 무음은 계기를 가리는 것이고, 이건 정말로 아무 일도 일어나지 않는 것이다. 만약 무음 파일을 먼저 배치했다면, 지금 정말 스캔의 물결이 와도 netdata는 잠자코 있을 테고 나는 유효한 신호 하나를 잃었을 것이다. 실제로는 울릴 때 대개 진짜 뭔가가 있다. 글머리의 그 한 장처럼, 그건 내 Spotify API가 루프를 돌고 있었다.

TIP

무음을 생각하기 전에 원인을 끊는다 경고가 시끄러울 때 첫 직감은 대개 임계값을 만지거나 무음으로 돌리는 것이다. 하지만 시끄러운 이유가 "정말로 나쁜 요청이 많이 오고 있다"는 것이라면, 원인을 없애면 경고도 함께 해결되고 경고의 변별력은 보존된다. 무음은 "그 경고가 애초에 존재하지 말았어야 하는" 경우를 위한 것이다.

fail2ban은 그 뒤 어떻게 됐나#

솔직히 말하면 제대로 은퇴시키지 않았다. CrowdSec이 돌기 시작한 뒤로는 신경 쓰지 않았고, 나중에야 어색한 상태로 멈춰 있는 걸 발견했다. 서비스는 돌지 않고 설정 파일은 남아 있다.

console
$ systemctl is-active fail2ban
inactive
$ systemctl is-enabled fail2ban
disabled

멈춘 경위도 좀 웃긴다. LAN 화이트리스트를 넣으려고 jail.d/에 파일 하나를 뒀는데, 그 파일 때문에 부팅 시 파싱에 실패해서 그대로 올라오지 않게 됐다. 그리고 알아차리지 못했다. 그 기간 내내 CrowdSec이 막고 있어서 증상이 하나도 없었기 때문이다.

여기서의 교훈은 "fail2ban이 나쁘다"가 아니다. 이렇다. 기능이 겹치는 방어 둘을 나란히 뒀을 때 가장 위험한 건 충돌이 아니라, 양쪽 다 지키고 있다고 착각하는 것이다. 그때 멈춘 게 CrowdSec이었다면 나도 똑같이 오래 몰랐을 것이다. 갈아탈 거면 깨끗이 갈아타고, 새것이 정말 돌고 있는지 확인할 것.

한 장짜리 체크리스트#

CrowdSec 자가 호스팅 체크리스트
  1. 설치 직후 cscli version. ESM에서 옛 버전을 받아도 오류는 나지 않지만, 이후의 문제는 전부 거기서 시작된다.
  2. 메이저 버전을 건너뛰었다면 끊어진 symlink를 확인: sudo find /etc/crowdsec -xtype l -deletecscli hub update + collections install --force.
  3. 파싱 검증은 cscli explain으로. cscli metrics의 비율만 보지 말 것. 그건 누적값이고 reload로는 지워지지 않으며 restart가 필요하다.
  4. SSH 수집은 journalctl만. auth.log도 함께 수집하면 실패가 두 번씩 세어진다.
  5. 첫날에 LAN whitelist parser를 설정. 그러지 않으면 SSH 비밀번호를 끄는 류의 작업에서 자기 내부망으로부터 갇히기 쉽다.
  6. 공개 출구 IP는 allowlist에 넣는다. LAN 화이트리스트는 밖에 나가 있을 때를 구해주지 못한다.
  7. nginx-req-limit-exceeded 주의. 내 429가 차단 장치에 먹이를 주므로 새로고침 연타로 내가 차단된다.
  8. 내게 막혔는지 판별하는 법: refused가 아니라 i/o timeout, 휴대폰은 정상인데 컴퓨터만 불통, 정적 파일조차 못 불러옴.
  9. SSH에 의존하지 않는 해제 경로를 준비. 게다가 "내 쪽에서 밖으로 연결하는" 구조여야 의미가 있다.
  10. CrowdSec은 기본적으로 알리지 않는다. profiles.yaml의 notifications는 주석 처리되어 있으니 원하면 직접 연결할 것.
  11. 경고가 시끄러우면 무음보다 원인을 먼저 끊는다. 스캐너를 막으면 4xx는 알아서 사라진다. 무음은 계기를 가릴 뿐이다.
  12. 옛것을 멈출 거면 깨끗이 멈춘다. 반쯤 죽은 서비스를 남겨 이중 보험이 있다고 착각하지 말 것.
參考連結