fail2ban이랑 UFW를 직접 파싱해서 서버 보안 대시보드를 만들어봤다
리눅스 보안 정보를 긁어와 대시보드로
#보안 #Linux #fail2ban #Node
내 서버에는 블로그, 코인 사이트, 커플 앱, 타로, 여행 플래너까지 여러 서비스가 한 대에서 돌아가고 있다. 포트만 봐도 5000번부터 5025번까지 쭉 늘어서 있는데, 어느 순간부터 이게 좀 무서워지기 시작했다. 누가 SSH로 로그인 시도를 하는지, 어떤 포트가 열려 있는지, fail2ban이 실제로 누굴 막고 있는지… 이런 걸 알려면 매번 서버에 들어가서 last, ss -tlnp, fail2ban-client status 같은 명령어를 직접 쳐야 했다. 근데 이게 은근 귀찮다. 그래서 그냥 이 정보들을 웹 대시보드 하나로 모아서 보기로 했다. 이 글에서는 리눅스 보안 정보를 직접 긁어와서 모니터링 대시보드로 만든 과정을 정리해본다. 무슨 정보를 봐야 하나 보안 상태를 한눈에 보려면 결국 이런 것들이 필요했다. 1. 열려 있는 포트 — 의도치 않게 외부에 뚫린 포트가 있는지 2. 최근 로그인 기록 — 내가 안 한 로그인이 있는지 3. 실패한 로그인 시도 — 어디서 비번을 털려고 하는지 4. fail2ban이 차단한 IP 수 5. 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 localAddr = parts[3]; const lastColon = localAddr.lastIndexOf(':'); const port = parseInt(localAddr.slice(lastColon + 1)); const rest = parts.slice(5).join(' '); const procMatch = rest.match(/users:\(\(\"([^"]+)\",pid=(\d+)/); const pid = procMatch?.[2]; const procName = pid ? await getProcCmd(pid) : ''; ports.push({ port, process: procName, pid }); } return ports.sort((a, b) = a.port - b.port); } PID로 /proc/{pid}/cmdline 을 읽으면 실제 실행 명령어를 알 수 있어서, "이 포트는 node app.js가 열어둔 거구나" 하는 걸 바로 알 수 있다. 단순히 포트 번호만 보는 것보다 훨씬 직관적이다. 실패한 로그인 추적하기 이게 제일 보고 싶었던 정보다. /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 로 한 번에 돌렸다. sudo 없이 UFW 상태 확인하기 UFW 상태는 보통 sudo ufw status로 보는데, 대시보드 프로세스에 sudo 권한을 주긴 싫었다. 그래서 그냥 /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 미들웨어를 붙여서, 로그인한 관리자만 이 정보를 볼 수 있게 막았다. 보안 정보 자체가 민감하니까 이건 당연히 필요했다. 만들고 나서 이 대시보드를 만들고 나서 제일 좋았던 건, 서버에 SSH로 안 들어가도 브라우저에서 보안 상태를 바로 볼 수 있게 됐다는 거다. 특히 실패한 로그인 목록을 보면서 "아 이렇게 끊임없이 공격당하고 있구나"를 체감했고, 그 뒤로 fail2ban이랑 UFW를 훨씬 더 신경 써서 설정하게 됐다. 사실 거창한 기술이 들어간 건 아니다. 리눅스가 이미 다 기록하고 있는 정보를, 명령어로 긁어서 파싱하고, 보기 좋게 모아준 것뿐이다. 근데 직접 만들어보니 내 서버가 지금 어떤 상태인지 훨씬 잘 알게 됐고, 보안을 막연한 불안이 아니라 눈에 보이는 숫자로 관리할 수 있게 됐다. 혹시 개인 서버를 직접 운영하고 있다면, 이런 대시보드 하나쯤 만들어두는 걸 추천한다. 만드는 김에 리눅스가 어디에 뭘 기록하는지도 자연스럽게 알게 되니까.