fail2ban이랑 UFW를 직접 파싱해서 서버 보안 대시보드를 만들어봤다
SSH로 들어가 명령어를 치던 보안 점검을 웹 대시보드 하나로 모았다. 열린 포트, 로그인 실패 기록, fail2ban, UFW 상태를 Node에서 읽어 파싱한 코드와 sudo 없이 권한 문제를 피한 방법을 적었다.
#보안 #Linux #fail2ban #Node
내 서버에는 블로그, 코인 사이트, 커플 앱, 타로, 여행 플래너까지 여러 서비스가 한 대에서 돌아가고 있다. 포트만 봐도 5000번부터 5025번까지 늘어서 있는데, 어느 순간부터 이게 좀 무서워지기 시작했다. 누가 SSH로 로그인 시도를 하는지, 어떤 포트가 열려 있는지, fail2ban이 실제로 누굴 막고 있는지 같은 걸 알려면 매번 서버에 들어가서 last, ss -tlnp, fail2ban-client status 같은 명령어를 직접 쳐야 했다. 근데 이게 은근 귀찮다. 한두 번은 치는데, 매일 확인하게 되지는 않는다. 그래서 그냥 이 정보들을 웹 대시보드 하나로 모아서 보기로 했다. 이 글에서는 리눅스 보안 정보를 직접 긁어와서 모니터링 대시보드로 만든 과정을 정리해본다. 무슨 정보를 봐야 하나 보안 상태를 한눈에 보려면 결국 다섯 가지가 필요했다. 첫째는 열려 있는 포트다. 의도치 않게 외부에 뚫린 포트가 있는지 보고 싶었다. 둘째는 최근 로그인 기록으로, 내가 안 한 로그인이 있는지 확인하려는 거다. 셋째는 실패한 로그인 시도인데, 어디서 비밀번호를 털려고 하는지 알 수 있다. 넷째는 fail2ban이 지금 차단하고 있는 IP 수, 다섯째는 UFW 방화벽이 켜져 있는지 여부다. 전부 리눅스 명령어로 이미 볼 수 있는 것들이다. 문제는 이걸 매번 손으로 친다는 거였고, 그래서 Node에서 명령어를 실행하고 결과를 파싱하는 식으로 풀었다. 명령어를 Node에서 실행하기 기본 골격은 간단하다. child_process의 exec로 명령어를 돌리고 결과를 받아오는 함수 하나를 만들었다. function run(cmd, timeout = 5000) { return new Promise((resolve) = { exec(cmd, { timeout }, (err, stdout) = { resolve(err ? '' : stdout.trim()); }); }); } 여기서 신경 쓴 부분은 timeout이다. 보안 정보 조회 때문에 대시보드가 멈추면 안 되니까, 5초 안에 응답이 없으면 그냥 넘어가게 했다. 에러가 나도 throw하지 않고 빈 문자열을 resolve한다. 보안 페이지는 일부 정보가 안 와도 나머지는 보여줘야 하니까, 하나가 실패했다고 화면 전체가 빈 채로 뜨는 것보다 그 칸만 비는 게 낫다고 봤다. 열린 포트 긁어오기 포트는 ss -tlnp로 본다. LISTEN 상태인 것만 골라서, 주소랑 포트, 그리고 그 포트를 누가 물고 있는지(프로세스 이름, PID)까지 뽑았다. async function getOpenPorts() { const out = await run('ss -tlnp'); const lines = out.split('\n').slice(1); const ports = []; for (const line of lines) { const parts = line.trim().split(/\s+/); if (parts[0] !== 'LISTEN' || parts.length 5) continue; const [addr, port] = parts[3].split(/:(?=[^:]*$)/); const pid = (parts[5] || '').match(/pid=(\d+)/)?.[1]; ports.push({ addr, port: Number(port), pid }); } return ports.sort((a, b) = a.port - b.port); } PID로 /proc/{pid}/cmdline을 읽으면 실제 실행 명령어를 알 수 있어서, 이 포트는 node app.js가 열어둔 거구나 하는 걸 바로 알 수 있다. 단순히 포트 번호만 보는 것보다 훨씬 직관적이다. 그리고 주소가 127.0.0.1인지 0.0.0.0인지도 같이 보여주는데, 이게 생각보다 중요했다. 같은 LISTEN이라도 앞의 것은 이 컴퓨터 안에서만 접속되고, 뒤의 것은 바깥에서도 들어올 수 있기 때문이다. 실패한 로그인 추적하기 이게 제일 보고 싶었던 정보다. /var/log/auth.log에는 누가 어디서 로그인에 실패했는지가 다 찍힌다. 여기서 시간, 유저, IP, 실패 유형을 정규식으로 뽑았다. async function getFailedLoginsList() { const out = await run( 'grep "Failed password\\|Invalid user" /var/log/auth.log | tail -50' ); return out.split('\n').filter(Boolean).reverse().map(line = { const time = (line.match(/^(\S+T[\d:.+]+)/) || [])[1] || ''; const ip = (line.match(/from (\d+\.\d+\.\d+\.\d+)/) || [])[1] || ''; const type = line.includes('Failed password') ? 'Failed password' : line.includes('Invalid user') ? 'Invalid user' : 'Auth failure'; return { time, ip, type }; }); } 실제로 돌려보면 진짜 많다. 전 세계에서 root, admin, test 같은 흔한 계정명으로 끊임없이 비밀번호를 찔러본다. 이걸 직접 눈으로 보고 나니까 fail2ban이 왜 필요한지 확실히 와닿았다. fail2ban이 누굴 막고 있나 fail2ban-client status를 치면 jail 목록이 나오고, 각 jail마다 status를 또 치면 현재 차단된 IP 수가 나온다. 이걸 파싱했다. async function getFail2ban() { const out = await run('fail2ban-client status'); const match = out.match(/Jail list:\s+(.+)/); if (!match) return []; const jailNames = match[1].split(',').map(s = s.trim()).filter(Boolean); return Promise.all(jailNames.map(async name = { const info = await run(`fail2ban-client status ${name}`); const banned = info.match(/Currently banned:\s+(\d+)/); return { name, banned: banned ? parseInt(banned[1]) : 0 }; })); } jail 이름을 먼저 뽑고, 각각에 대해 다시 status를 호출해서 Currently banned 숫자를 가져온다. jail이 여러 개라 Promise.all로 한 번에 돌렸다. 참고로 fail2ban-client는 소켓 권한 때문에 root로만 실행된다. 대시보드 프로세스가 일반 사용자로 돌면 이 명령은 빈 문자열을 돌려주는데, 그 얘기는 아래 UFW 부분과 이어진다. sudo 없이 UFW 상태 확인하기 UFW 상태는 보통 sudo ufw status로 보는데, 대시보드 프로세스에 sudo 권한을 주긴 싫었다. 웹에서 접근하는 프로세스가 root 권한을 들고 있으면, 그 프로세스에 구멍이 하나만 나도 서버 전체가 넘어간다. 그래서 그냥 /etc/ufw/ufw.conf 파일을 직접 읽어서 ENABLED=yes인지 확인하는 식으로 우회했다. async function getUfwStatus() { try { const conf = await fs.readFile('/etc/ufw/ufw.conf', 'utf8'); const match = conf.match(/^ENABLED=(\w+)/m); if (match) return match[1].toLowerCase() === 'yes' ? 'active' : 'inactive'; } catch {} return '알 수 없음'; } 권한 문제를 명령어가 아니라 파일 읽기로 푼 건데, 이런 식으로 sudo를 피할 수 있는 경우가 생각보다 많다. 굳이 위험한 권한을 프로세스에 주지 않아도 되는 거다. 물론 이 방법은 설정 파일이 켜져 있다고 말하는지를 보는 거지 실제로 방화벽이 돌고 있는지를 보는 건 아니다. 그 정도 차이는 감수하기로 했다. 한 번에 모아서 응답하기 마지막으로 이 함수들을 Promise.all로 전부 병렬 실행해서 하나의 JSON으로 내려줬다. 하나하나 await하면 느리니까, 서로 독립적인 조회는 다 동시에 돌리는 게 맞다. router.get('/', authenticateToken, requireNonGuest, async (req, res) = { const [openPorts, recentLogins, failedLoginsList, ufwStatus, fail2banJails] = await Promise.all([ getOpenPorts(), getRecentLogins(), getFailedLoginsList(), getUfwStatus(), getFail2ban(), ]); res.json({ openPorts, recentLogins, failedLoginsList, ufwStatus, fail2banJails }); }); 여기에 authenticateToken이랑 requireNonGuest 미들웨어를 붙여서, 로그인한 관리자만 이 정보를 볼 수 있게 막았다. 어떤 포트가 열려 있고 어떤 IP가 로그인을 시도했는지는 그 자체가 공격에 쓰일 수 있는 정보라, 이건 당연히 필요했다. 만들고 나서 이 대시보드를 만들고 나서 제일 좋았던 건, 서버에 SSH로 안 들어가도 브라우저에서 보안 상태를 바로 볼 수 있게 됐다는 거다. 특히 실패한 로그인 목록을 보면서 이렇게 끊임없이 공격당하고 있구나 하는 걸 체감했고, 그 뒤로 fail2ban이랑 UFW를 훨씬 더 신경 써서 설정하게 됐다. 사실 거창한 기술이 들어간 건 아니다. 리눅스가 이미 다 기록하고 있는 정보를, 명령어로 긁어서 파싱하고, 보기 좋게 모아준 것뿐이다. 근데 직접 만들어보니 내 서버가 지금 어떤 상태인지 훨씬 잘 알게 됐고, 보안을 막연한 불안이 아니라 눈에 보이는 숫자로 관리할 수 있게 됐다. 약점 하나 이 대시보드는 전부 셸 명령을 exec로 돌려서 결과를 파싱하는 방식이다. const out = await run('fail2ban-client status'); const info = await run(`fail2ban-client status ${name}`); const banned = info.match(/Currently banned:\s+(\d+)/); 정규식으로 fail2ban 출력에서 숫자를 긁어온다. 지금은 잘 돌지만 fail2ban이 출력 형식을 바꾸면 조용히 0이 뜬다. 에러가 아니라 0이라서 알아채기도 어렵다. 위에서 말한 권한 문제도 같은 모양으로 나타난다. 명령이 실패해도 run()이 빈 문자열을 돌려주니 차단 IP가 0개인 것처럼 보인다. 정석은 fail2ban 소켓 API를 쓰는 거다. 다만 개인 서버 대시보드 하나에 그만한 시간을 들이는 게 맞나 싶어서 파싱으로 갔다. 대신 값이 갑자기 0으로 떨어지면 알림이 오게 해두는 편이 현실적이라, 다음에 그걸 붙일 생각이다. 명령어 출력을 파싱하는 대시보드를 만든다면, 값이 0으로 나오는 게 진짜 0인지 실패인지 구분하는 장치를 처음부터 같이 넣어두길 추천한다.