Perl in the Home Lab — The Perl Community Conference :: Summer 2026

Perl in the Home Lab

Three tiny CGI projects that would be miserable in anything else

The Perl Community Conference
Summer 2026

dhcp-manager mega home-interface

Demo

The itch

  • 18-server home lab. Real hardware, not containers on a laptop.
  • Every box has an LSI RAID controller, DHCP leases, LVM volumes, log files.
  • The tools that answer “is this drive OK?” are all text-in, text-out command line binaries.
  • I wanted a browser dashboard — without dragging in a framework, a bundler, a build step, or a package manager.
The premise: if the input is text from a system tool and the output is JSON for a browser, a Perl CGI script is still the shortest path in 2026.

Three projects, one shape

dhcp-manager

REST API in front of isc-dhcp-server. Parses dhcpd.leases, blocks/unblocks clients at the firewall.

mega

Web UI on top of the MegaCLI binary for LSI RAID controllers. Dashboard, drives, BBU, event log.

home-interface

The umbrella dashboard. Aggregates status across every server — storage, RAID, DHCP, traffic.

~730lines of Perl
3CGI scripts
0frameworks
0build steps
2CPAN deps
(CGI, JSON)
2under taint mode

Part 1

dhcp-manager

A REST API in front of isc-dhcp-server

What it does

INPUTS dhcpd.leases active leases, MAC & hostname dhcpd.conf static host reservations iptables set who is currently blocked manage_client.sh sudo helper for block / unblock api.pl -T Perl 5 · taint mode CGI.pm JSON::XS Fcntl (flock) parse · validate merge · enrich JSON API ?action= list ?action= status ?action= block ?action= unblock Browser dashboard plain HTML + fetch() toggles on the network sudo Every arrow is a regex, a hash, or a system() call — nothing more.
Everything interesting is text parsing. The ISC lease file format is bespoke, whitespace-sensitive, and nested. This is Perl’s home turf.

Architecture

Browser | fetch('/api.pl?action=list') v Apache + suexec ------> api.pl -T (Perl, taint mode) | +--- reads /var/lib/dhcp/dhcpd.leases +--- reads /etc/dhcp/dhcpd.conf +--- shells out to sudo manage_client.sh | v JSON on stdout

One file, one script, one process per request. No daemon to babysit.

Taint mode from line one

api.pl:1

#!/usr/bin/perl -T

use strict;
use warnings;
use CGI;
use JSON::XS;
use Fcntl qw(:flock);

# The -T flag makes Perl refuse to use any data that came from
# outside the program (query strings, environment, file contents)
# in a "dangerous" context -- shelling out, opening a file, etc. --
# until we've explicitly untainted it with a regex capture.
Why this matters: the script eventually runs sudo manage_client.sh <ip>. Getting user input into that command line without taint mode is a CVE waiting to happen. Perl gives you a runtime safety net that no other scripting language bundles by default.

Untainting = validating

api.pl : validate_ip()

sub validate_ip {
    my ($raw) = @_;
    return undef unless defined $raw;

    if ($raw =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) {
        my $ip = $1;                # <-- untainted by the capture
        my @octets = split /\./, $ip;

        for my $o (@octets) {
            return undef if $o > 255;
        }
        return undef if $octets[0] == 0;      # 0.x.x.x
        return undef if $octets[0] == 127;    # loopback
        return undef if $octets[0] >= 224;    # multicast / reserved

        return $ip;
    }
    return undef;
}

The regex capture is what makes $1 safe to pass to a shell. “Validate” and “untaint” become the same operation.

Parsing dhcpd.leases

api.pl : parse_leases()

open(my $fh, '<', $LEASES_FILE) or return {};
flock($fh, LOCK_SH);

my ($in_lease, $current_ip);
my %leases;

while (my $line = <$fh>) {
    chomp $line; $line =~ s/^\s+//;

    if ($line =~ /^lease\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*\{/) {
        $in_lease   = 1;
        $current_ip = $1;
    }
    elsif ($in_lease && $line =~ /^hardware\s+ethernet\s+([0-9a-fA-F:]+)\s*;/) {
        $leases{$current_ip}{mac} = validate_mac($1) // '';
    }
    elsif ($in_lease && $line =~ /^client-hostname\s+"([^"]*)"\s*;/) {
        $leases{$current_ip}{hostname} = $1;
    }
    elsif ($line =~ /^\}/) {
        $in_lease = 0;
    }
}

Six regexes, a state flag, a hash. This is the whole parser. Try writing that in Go without missing lunch.

Why Perl for dhcp-manager?

  • Regex as first-class syntax. The lease format has ~10 tokens. Ten regexes, ten lines each. No lexer, no parser generator, no dependencies.
  • -T taint mode. Nothing else ships this. Python has ast-grep, Ruby has $SAFE (deprecated). Perl gives me a hard runtime failure the moment I shell out with untrusted data.
  • CGI is a feature. The script is stateless. Apache spawns it, it prints, it dies. No memory leaks. No connection pool. No zero-downtime deploy — because there’s no daemon to restart.
  • Small deps. CGI + JSON::XS. One apt-get install libjson-xs-perl and you’re done.

Part 2

mega

A browser UI for LSI MegaRAID controllers

The problem

$ ssh srv01 $ ssh srv02 $ ssh srv03 $ ssh ... One admin many tabs, no time 18 SERVERS srv01 srv02 srv03 srv04 srv05 srv06 srv07 srv08 srv09 srv10 srv11 srv12 srv13 srv14 srv15 srv16 srv17 srv18 18 × ssh $ sudo megacli -LDInfo -Lall -aALL Adapter 0 -- Virtual Drive Information: Virtual Drive: 0 (Target Id: 0) Name : RAID Level : Primary-1, Secondary-0, RAID Level Qualifier-0 Size : 1.818 TB Sector Size : 512 State : Optimal Strip Size : 64 KB Number Of Drives : 2 ← unstructured text. no --json. never will be. 18 servers × up to 4 adapters × up to 24 drives = a lot of SSH sessions
The job: shell out to megacli, parse the text, emit JSON. That’s it. That’s the whole product.

Architecture

CLIENT HTTP CGI SHELL /mega/ Browser jQuery SPA, dark theme GET ?action=summary&adapter=0 nginx + fcgiwrap static files · CGI dispatch location /mega/api/ { fastcgi_pass ... } megacli.pl -T dispatch table %dispatch = ( action => \&fn ) untaint + validate params parse_kv_block() text => hash-of-hashes JSON::XS encode sudo megacli -LDInfo ... searches 6 well-known install paths HTTP request JSON CGI env stdout backtick exec stdout text BY THE NUMBERS 251 lines of Perl 10 dispatched actions 2 CPAN modules 1 file to deploy

Same shape as dhcp-manager. Different tool being wrapped.

Dispatch table — coderefs in a hash

megacli.pl : action dispatch

my %dispatch = (
    'adapters'     => \&get_adapters,
    'adapter_info' => \&get_adapter_info,
    'summary'      => \&get_summary,
    'vdrives'      => \&get_virtual_drives,
    'pdrives'      => \&get_physical_drives,
    'pdrive_info'  => \&get_physical_drive_info,
    'bbu'          => \&get_bbu_status,
    'rebuild'      => \&get_rebuild_progress,
    'enclosures'   => \&get_enclosures,
    'event_log'    => \&get_event_log,
);

my $action = param_word('action', 'summary');

if (exists $dispatch{$action}) {
    my $result = eval { $dispatch{$action}->() };
    print $json->encode($@ ? { error => "$@" } : $result);
} else {
    print $json->encode({ error => "unknown action: $action" });
}

A hash of coderefs is your router. No framework, no annotations, no reflection. Perl treats functions as data — use it.

Untaint helpers — the pattern, extracted

megacli.pl : untaint helpers

sub untaint {
    my ($val, $re) = @_;
    return undef unless defined $val;
    return ($val =~ /($re)/) ? $1 : undef;
}

sub param_digits {
    my ($name, $default) = @_;
    my $raw   = $cgi->param($name) // $default;
    my $clean = untaint($raw, qr/\d+/);
    return defined $clean ? $clean : $default;
}

# Later, when building the command line:
my $adapter = param_digits('adapter', 0);
my $out = `sudo $MEGACLI -LDInfo -Lall -a$adapter -NoLog`;
Compiled regexes as first-class values. qr/\d+/ gives you a regex object you can pass around, cache, and compose. This is why the untaint helper is three lines.

Parsing MegaCLI output

megacli.pl : parse_kv_block()

# MegaCLI emits blocks like:
#
#   Virtual Drive: 0 (Target Id: 0)
#   Name                :
#   RAID Level          : Primary-1, Secondary-0, RAID Level Qualifier-0
#   Size                : 1.818 TB
#   State               : Optimal
#   Number Of Drives    : 2

sub parse_kv_block {
    my ($text) = @_;
    my %data;
    for my $line (split /\n/, $text) {
        if ($line =~ /^\s*(.+?)\s*:\s*(.+?)\s*$/) {
            my ($k, $v) = ($1, $2);
            $k =~ s/\s+/_/g;         # "RAID Level" -> "RAID_Level"
            $data{$k} = $v;
        }
    }
    return \%data;
}

Twelve lines. That’s the whole parser for a text format that has actively resisted standardization for two decades.

Why Perl for mega?

  • The problem is text munging. Every action is: run a binary → regex the output → return a hash. Perl was invented for exactly this.
  • Hashes-of-hashes as the data model. No ORM, no schema, no dataclasses. The parser returns a nested hash; JSON::encode turns it into a browser response. One data structure end-to-end.
  • Backticks. Yes, backticks. my $out = `sudo megacli ...`; — combined with taint mode — is a perfectly reasonable subprocess API when the input has been untainted through a regex.
  • Deployment is cp. One .pl file into the CGI directory. No virtualenv, no go build, no container. The script is the artifact.

Part 3

home-interface

The umbrella dashboard for the whole lab

What it is

  • A set of HTML pages: dashboard, storage, raid, dhcp, traffic, tools
  • A shared servers.js declaring six named servers: Templar, Mage, Paladin, Druid, Ranger, Knight
  • Each page fetches JSON from a CGI endpoint on the target server
  • Two of those endpoints are dedicated Perl scripts here — the rest are the two projects you just saw
Composition. home-interface is what happens when you stop building monoliths and just let each tool be its own tiny CGI URL. Perl makes each tool cheap enough that this is viable.

The whole raid.pl endpoint

cgi-bin/raid.pl — 47 lines, complete

#!/usr/bin/perl
use strict;
use warnings;

my $STORCLI = "/opt/MegaRAID/storcli/storcli64";

print "Content-Type: application/json; charset=utf-8\r\n";
print "Access-Control-Allow-Origin: *\r\n";
print "Cache-Control: no-store\r\n";
print "\r\n";

if (! -x $STORCLI) {
    print qq({"error":"storcli not found at $STORCLI"});
    exit 0;
}

my $cmd = "sudo -n $STORCLI /call show all J 2>&1";
my $out = `$cmd`;
my $rc  = $? >> 8;

if ($rc != 0) {
    my $msg = $out;
    $msg =~ s/\\/\\\\/g;
    $msg =~ s/"/\\"/g;
    $msg =~ s/[\r\n]+/\\n/g;
    print qq({"error":"storcli exited with $rc","output":"$msg"});
    exit 0;
}

print $out;   # storcli already emits JSON with the "J" flag

What just happened?

  • An HTTP endpoint. Fully self-contained. 47 lines.
  • No routing library. No framework. No import express.
  • Headers with a literal print. CORS included.
  • The tool already speaks JSON — so the “parser” is print $out.
  • Error handling: capture stderr, escape the four characters that break JSON, ship it.
This is the killer feature. The distance between “I have a shell command” and “I have a monitored HTTP endpoint” is 47 lines of Perl and a symlink into cgi-bin/.

Why Perl for home-interface?

  • New endpoint = new file. Adding a metric doesn’t mean editing a router, a schema, and a controller. It means dropping a .pl into cgi-bin/.
  • Independent failure domains. If raid.pl hangs, it doesn’t take lvs.pl with it. Each request is a fresh process. That’s a feature I’m paying for elsewhere in every long-running server.
  • Perl is already on every server. Debian, RHEL, Alpine, FreeBSD. “What’s the runtime version?” is a question I have never had to answer on any of these boxes.
  • Old code keeps working. These scripts will run on Perl 5.10 and Perl 5.40 with no changes. Try that with your favorite JS runtime.

The pattern, in three bullets

  1. Text tool ships text. megacli, lvs, storcli, dhcpd.leases. You’re not going to change that.
  2. Browser wants JSON. You’re also not going to change that.
  3. Perl is the shortest bridge. Regex + hashes + CGI.pm + JSON. Under 100 lines per endpoint.

What Perl gave me that other languages didn’t

  • Taint mode. A first-class runtime safety feature for exactly this class of program.
  • Regex-as-syntax. Not a library. Not an object. $line =~ /pattern/ is the language.
  • CGI as a delivery model. Everyone else abandoned it. That’s fine. For 18 servers behind my firewall, spawning a process per request is a feature, not a scale problem.
  • Zero-install deployment. scp file.pl. Done.
  • Longevity. This code will still run in 2036. Same syntax, same modules.

What I’m not claiming

  • That you should build your next SaaS on CGI.
  • That there’s no place for Mojolicious, Dancer2, or PSGI in modern Perl — there absolutely is.
  • That Perl is easy for beginners. It’s not. It has warts.
What I am claiming: for a specific, common, sysadmin-shaped problem — wrap a text tool, expose it over HTTP, keep it running for a decade — Perl is still the best tool on the shelf. And it’s not close.

Thank you

Questions?

dhcp-manager · mega · home-interface
The Perl Community Conference · Summer 2026

Note: You are viewing an auto-generated directory index.