I built a server security dashboard by parsing fail2ban and UFW myself
I moved my SSH security checks into one web dashboard. Covers the Node code that reads open ports, failed logins, fail2ban and UFW status, and how I avoided sudo.
#Security #Linux #fail2ban #Node
My server runs several services on a single machine: the blog, the crypto site, the couples app, tarot and the travel planner. Just looking at the ports, they line up from 5000 to 5025, and at some point this started to scare me a little. To find out who was trying to log in over SSH, which ports were open, or who fail2ban was actually blocking, I had to get on the server every time and type commands like last, ss -tlnp, fail2ban-client status myself. But that's more of a hassle than it sounds. I'll type them once or twice, but I don't end up checking every day. So I decided to just gather this information into a single web dashboard. In this post I'll go over how I scraped Linux security information myself and turned it into a monitoring dashboard. What information do I need to see? To see the security state at a glance, I ended up needing five things. The first is open ports. I wanted to see whether any port was exposed to the outside without my meaning it to be. The second is the recent login history, to check for logins I didn't make. The third is failed login attempts, which tell me where someone is trying to crack the password from. The fourth is the number of IPs fail2ban is blocking right now, and the fifth is whether the UFW firewall is on. All of these can already be seen with Linux commands. The problem was typing them by hand every time, so I solved it by running the commands from Node and parsing the output. Running commands from Node The basic skeleton is simple. I made one function that runs a command with exec from child_process and brings back the result. function run(cmd, timeout = 5000) { return new Promise((resolve) = { exec(cmd, { timeout }, (err, stdout) = { resolve(err ? '' : stdout.trim()); }); }); } The part I paid attention to here is the timeout. The dashboard shouldn't hang because of a security lookup, so if there's no response within 5 seconds it just moves on. Even when there's an error it doesn't throw, it resolves an empty string. A security page should still show the rest even if some of the information doesn't arrive, so I figured it was better for just that one box to be empty than for the whole screen to come up blank because one thing failed. Scraping open ports Ports come from ss -tlnp. I picked only the ones in the LISTEN state and pulled out the address, the port, and who is holding that port (process name, 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); } Reading /proc/{pid}/cmdline with the PID gives the actual command that was run, so I can tell right away that this port was opened by node app.js. It's much more intuitive than looking at a bare port number. It also shows whether the address is 127.0.0.1 or 0.0.0.0, and that mattered more than I expected. Both are LISTEN, but the former can only be reached from inside this machine, while the latter can be reached from outside too. Tracking failed logins This was the information I wanted to see most. /var/log/auth.log records everything about who failed to log in and from where. I pulled the time, user, IP and failure type out of it with regular expressions. 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 }; }); } When you actually run it, there are really a lot. From all over the world, people keep poking at passwords nonstop with common account names like root, admin and test. After seeing this with my own eyes, it really sank in why fail2ban is needed. Who is fail2ban blocking? Running fail2ban-client status gives the list of jails, and running status again for each jail gives the number of currently banned IPs. I parsed that. 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 }; })); } It pulls the jail names first, then calls status again for each one to get the Currently banned number. There are several jails, so I ran them all at once with Promise.all. For reference, fail2ban-client only runs as root because of the socket permissions. If the dashboard process runs as a regular user, this command returns an empty string, and that ties into the UFW part below. Checking UFW status without sudo UFW status is usually checked with sudo ufw status, but I didn't want to give the dashboard process sudo rights. If a process that's reachable from the web holds root privileges, a single hole in that process hands over the whole server. So I worked around it by just reading the /etc/ufw/ufw.conf file directly and checking whether it says 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 'unknown'; } I solved a permission problem with a file read instead of a command, and there are more cases than you'd think where sudo can be avoided this way. There's no need to hand a dangerous privilege to the process. Of course, this method checks whether the config file says it's enabled, not whether the firewall is actually running. I decided to live with that much of a gap. Gathering it all into one response Finally, I ran all of these functions in parallel with Promise.all and returned them as a single JSON. Awaiting them one by one is slow, so lookups that are independent of each other should all run at the same time. 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 }); }); I attached the authenticateToken and requireNonGuest middleware here so that only a logged-in admin can see this information. Which ports are open and which IPs tried to log in is itself information that can be used in an attack, so this was obviously necessary. After building it The best thing after building this dashboard was that I could see the security state right in the browser without getting on the server over SSH. Looking at the failed login list in particular, it hit me how constantly the server was being attacked, and after that I started configuring fail2ban and UFW with a lot more care. Honestly, there's nothing technically fancy in this. It just takes information Linux is already recording, scrapes it with commands, parses it, and gathers it in a readable form. But having built it myself, I know much better what state my server is in right now, and I can manage security as numbers I can see instead of as a vague worry. One weakness This whole dashboard works by running shell commands with exec and parsing the results. const out = await run('fail2ban-client status'); const info = await run(`fail2ban-client status ${name}`); const banned = info.match(/Currently banned:\s+(\d+)/); It scrapes the number out of fail2ban's output with a regular expression. It runs fine now, but if fail2ban changes its output format, it will quietly show 0. Because it's a 0 and not an error, it's also hard to notice. The permission problem I mentioned above shows up the same way. Even when the command fails, run() returns an empty string, so it looks like there are 0 blocked IPs. The proper way is to use the fail2ban socket API. But I wasn't sure it was right to spend that much time on one personal server dashboard, so I went with parsing. The more realistic option is to have an alert go off when the value suddenly drops to 0, so I plan to add that next. If you build a dashboard that parses command output, I'd recommend putting in something from the start that tells apart whether a 0 is a real 0 or a failure.