[{"content":"The blog you\u0026rsquo;re reading changed builders. The git push that publishes it is identical; what runs on the other end isn\u0026rsquo;t. It used to be a GitHub Actions job that built the site and served it from yourlocalunemployed.github.io. Now Cloudflare Pages builds from the same repo and serves it at billalrehmani.pages.dev.\nI didn\u0026rsquo;t move for speed or for a nicer dashboard. I moved because GitHub Pages won\u0026rsquo;t let me set HTTP response headers, and for a blog that\u0026rsquo;s meant to be a security portfolio, that was the one limit I couldn\u0026rsquo;t design around.\nThe actual problem: a meta tag is not response headers On GitHub Pages the only place I could express a Content-Security-Policy was a \u0026lt;meta http-equiv\u0026gt; tag in the page \u0026lt;head\u0026gt;:\n\u0026lt;meta http-equiv=\u0026#34;Content-Security-Policy\u0026#34; content=\u0026#34;default-src \u0026#39;self\u0026#39;; script-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; https://giscus.app; ...; base-uri \u0026#39;self\u0026#39;; form-action \u0026#39;self\u0026#39;;\u0026#34;\u0026gt; That works for the directives a meta tag is allowed to carry. The problem is the ones it isn\u0026rsquo;t. Browsers ignore frame-ancestors, report-uri, and sandbox when the CSP arrives via \u0026lt;meta\u0026gt; — they\u0026rsquo;re only honoured as a real response header. So the directive that actually stops my pages being framed for clickjacking was silently doing nothing.\nAnd that\u0026rsquo;s just CSP. A whole set of security headers can only exist as response headers — there is no meta equivalent at all:\nStrict-Transport-Security (HSTS) X-Content-Type-Options: nosniff X-Frame-Options Permissions-Policy Cross-Origin-Opener-Policy On GitHub Pages, none of those were being sent. There\u0026rsquo;s no setting for it — Pages doesn\u0026rsquo;t expose custom response headers, full stop. My security posture was capped at whatever a meta tag could say, and a meta tag can\u0026rsquo;t say most of it.\nCloudflare Pages reads a _headers file and turns it into real response headers. That single capability is the whole reason for the move.\nThe _headers file Hugo copies anything in static/ verbatim into public/, so static/_headers lands at the site root where Cloudflare looks for it:\n/* Content-Security-Policy: default-src \u0026#39;self\u0026#39;; script-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; https://giscus.app; style-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; https://giscus.app; font-src \u0026#39;self\u0026#39;; img-src \u0026#39;self\u0026#39; data: https://avatars.githubusercontent.com; connect-src \u0026#39;self\u0026#39; https://giscus.app; frame-src https://giscus.app; base-uri \u0026#39;self\u0026#39;; form-action \u0026#39;self\u0026#39;; object-src \u0026#39;none\u0026#39;; frame-ancestors \u0026#39;none\u0026#39; Strict-Transport-Security: max-age=31536000 X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() X-XSS-Protection: 0 Cross-Origin-Opener-Policy: same-origin The /* matches every route. Note the two directives the meta version couldn\u0026rsquo;t enforce are back and doing real work now: object-src 'none' and frame-ancestors 'none'.\nI kept the \u0026lt;meta\u0026gt; CSP in the template as a fallback — it still applies during local hugo server preview, where nothing is serving a _headers file. The two have to stay in sync by hand, so there\u0026rsquo;s a comment at the top of _headers saying exactly that, plus a reminder that if I ever wire up GoatCounter analytics I have to add its origins to the CSP in both files. Duplicated config is a liability; the least I can do is leave a note next to both copies.\nPointing Hugo at the new URL baseURL has to match wherever the site actually lives, or every absolute link and the RSS feed point at the old host:\nbaseURL = \u0026#34;https://billalrehmani.pages.dev/\u0026#34; The build config that isn\u0026rsquo;t in the repo Cloudflare Pages\u0026rsquo; build settings live in its dashboard, not in a file in the repo, which caught me out for a minute — there\u0026rsquo;s nothing to git blame if it breaks. For the record:\nBuild command: hugo --gc --minify Output directory: public Environment: HUGO_VERSION = 0.163.3 (the extended build) Two things worth knowing. Pin HUGO_VERSION — Cloudflare\u0026rsquo;s default Hugo is old, and a version mismatch against what I run locally is exactly the kind of \u0026ldquo;works on my machine\u0026rdquo; gap I don\u0026rsquo;t want in a build I can\u0026rsquo;t see. And the extended build is fine here specifically because PaperMod ships plain CSS: no Dart Sass in the toolchain to install or break. The theme is a git submodule, and Cloudflare clones the repo recursively, so it comes down on its own — no extra step.\nRetiring the old pipeline For a short window both pipelines were live, which meant every push double-deployed: GitHub Actions rebuilt github.io and Cloudflare rebuilt pages.dev. Two copies of the site from one push is confusing and pointless, so I deleted the workflow:\ngit rm .github/workflows/hugo.yml That\u0026rsquo;s the whole retirement. With no workflow file, GitHub Actions has nothing to run, and a push now only reaches Cloudflare.\nOne manual step is left that can\u0026rsquo;t be done from the repo: GitHub → Settings → Pages → Source: None, to take the stale github.io copy offline. Deleting the Action stops new builds; it doesn\u0026rsquo;t unpublish what Pages already served.\nChecking it actually worked The point of the whole exercise was response headers, so that\u0026rsquo;s what I verified — straight from the command line, filtered down to the ones I care about:\ncurl -sI https://billalrehmani.pages.dev | grep -Ei \u0026#39;content-security|strict-transport|x-frame|x-content|referrer|permissions\u0026#39; Seeing strict-transport-security, x-frame-options, and the full CSP come back as actual headers — not as a meta tag the browser half-honours — is the difference the move was for. securityheaders.com tells the same story more legibly if you\u0026rsquo;d rather see a grade than read raw headers.\nWhat I traded Nothing about the writing workflow changed — it\u0026rsquo;s still notes in the repo, /newpost, review, git push. What changed underneath:\nGained: real security headers, plus Cloudflare\u0026rsquo;s edge for free — automatic TLS, a global CDN, and DDoS mitigation I don\u0026rsquo;t have to think about. Cost: the publish path now runs through both GitHub and Cloudflare, so both accounts hold 2FA and both matter. That\u0026rsquo;s a slightly wider blast radius than \u0026ldquo;just GitHub\u0026rdquo; — a fair trade for headers I couldn\u0026rsquo;t get any other way. public/ is still committed to the repo, which is now redundant since Cloudflare rebuilds it from source on every push. It\u0026rsquo;s harmless, so I\u0026rsquo;ve left it; pruning it is a cleanup for another day, not a blocker.\nSame git push. Different builder. This time the site tells the browser how to protect it.\n","permalink":"https://billalrehmani.pages.dev/posts/cloudflare-pages-migration-security-headers/","summary":"\u003cp\u003eThe blog you\u0026rsquo;re reading changed builders. The \u003ccode\u003egit push\u003c/code\u003e that publishes it is\nidentical; what runs on the other end isn\u0026rsquo;t. It used to be a GitHub Actions job\nthat built the site and served it from \u003ccode\u003eyourlocalunemployed.github.io\u003c/code\u003e. Now\nCloudflare Pages builds from the same repo and serves it at\n\u003ccode\u003ebillalrehmani.pages.dev\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"Same git push, different builder — now with real HTTP response headers\" loading=\"lazy\" src=\"/images/posts/cloudflare-migration-pipeline.svg\"\u003e\u003c/p\u003e\n\u003cp\u003eI didn\u0026rsquo;t move for speed or for a nicer dashboard. I moved because GitHub Pages\nwon\u0026rsquo;t let me set HTTP response headers, and for a blog that\u0026rsquo;s meant to be a\nsecurity portfolio, that was the one limit I couldn\u0026rsquo;t design around.\u003c/p\u003e","title":"Moving the Blog off GitHub Pages — for Real Security Headers"},{"content":"A flat network trusts every device on it equally. A smart plug, a guest\u0026rsquo;s phone, and the machine holding my important data all share one space — so if any one of them is compromised, the attacker can reach the rest. Network segmentation breaks that flat space into separate zones and controls what may cross between them, shrinking the blast radius of any single compromise.\nI wanted to build that properly — VLANs and a firewall — on VMware Workstation with pfSense CE, and design three zones at deliberately different trust levels:\nTrusted — full access to the internet, other zones, and firewall administration. IoT — internet access only; blocked from other zones and from the firewall\u0026rsquo;s admin interface. Guest — internet access only; blocked from everything internal. The success criterion wasn\u0026rsquo;t \u0026ldquo;the config saved\u0026rdquo; — it was demonstrable isolation: proving, from inside each zone, exactly what it can and cannot reach. The whole thing was built additively, alongside my existing lab firewall, with nothing torn down.\nThe constraint: no hardware VLAN segmentation is normally taught with physical gear: a managed switch that supports 802.1Q tagging, a router or firewall that can do inter-VLAN routing, and cabling between them. I didn\u0026rsquo;t have a managed switch — so I built the whole thing virtually, and the concepts map across cleanly:\nPhysical component Virtual equivalent Managed switch A VMware host-only virtual network (the shared \u0026ldquo;switch fabric\u0026rdquo;) 802.1Q trunk port A dedicated virtual trunk NIC on the firewall Router-on-a-stick pfSense doing routing + firewalling for all VLANs Physical test PCs One Linux VM that tags itself into each VLAN in turn To act as a real client I built a persistent Linux desktop VM (EndeavourOS / KDE) rather than a throwaway live image — a machine I could actually browse and run tools from inside each segment. That turned \u0026ldquo;did the config apply?\u0026rdquo; into \u0026ldquo;can I see the firewall blocking me?\u0026rdquo;\nArchitecture I added a new virtual trunk interface to the firewall and connected it to an isolated host-only virtual network. Three VLANs sit on top of that single trunk, each becoming its own routed sub-interface with its own subnet and DHCP scope:\nVLAN Zone Gateway / subnet Intended access 10 Trusted 10.10.10.1/24 Full access 20 IoT 10.10.20.1/24 Internet only; no internal, no admin 30 Guest 10.10.30.1/24 Internet only; nothing internal The work was strictly additive: the trunk and its VLANs were built alongside the existing firewall setup (WAN, LAN, VPN, and monitoring), which I left completely untouched. I took a configuration backup and a VM snapshot first as a rollback point.\nThe build From an empty NIC to three live, addressed segments:\nSnapshot \u0026amp; back up. Firewall config export plus a VM snapshot, so any misstep is reversible. Create the virtual switch. An isolated host-only network with its own DHCP server disabled — the firewall, not VMware, hands out addresses on each VLAN. Define the VLANs. Three 802.1Q VLANs (tags 10 / 20 / 30) on the trunk NIC. Assign \u0026amp; address. Each VLAN promoted to a routed interface with a static gateway IP and a clear name. DHCP scopes. A per-VLAN address pool so clients auto-configure into the right subnet. Firewall rules. The rules that turn three open networks into three isolated ones. To avoid re-typing subnets in every rule, I created a reusable alias covering all private address space (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). \u0026ldquo;Block everything internal\u0026rdquo; then becomes a single, self-documenting rule.\nThe firewall logic pfSense evaluates rules top-down, first match wins, and anything not explicitly allowed is denied by default. It\u0026rsquo;s also stateful — when it permits a connection, the return traffic is allowed automatically. That last point is why a Trusted device can talk to IoT while IoT can\u0026rsquo;t start a conversation back.\nEach restricted zone (IoT, Guest) uses the same four-rule pattern. Order is the whole design:\n# Action Destination What it does 1 Pass firewall :53 Allow DNS lookups to the firewall 2 Block firewall No access to the admin interface / SSH 3 Block RFC1918 No reaching any other internal zone 4 Pass any Everything left over = the internet The DNS rule must sit above the block rules: the DNS server is the firewall itself, which lives inside private space — if the broad \u0026ldquo;block internal\u0026rdquo; rule came first, it would swallow DNS and every device would look connected but resolve nothing. Rule 4 needs no explicit \u0026ldquo;allow internet\u0026rdquo; target, because by the time a packet reaches it, \u0026ldquo;internet\u0026rdquo; is simply everything that wasn\u0026rsquo;t internal. Trusted, by contrast, gets a single pass any → any rule.\nVerification Same machine, different tag, different access — that asymmetry is the segmentation.\nWith the firewall built, all three VLAN interfaces came up healthy and addressed:\nTrusted (VLAN 10) — full access I tagged the test client into VLAN 10, pulled a DHCP lease, and every path succeeded:\n$ ip -br a show ens33.10 ens33.10@ens33 UP 10.10.10.100/24 fe80::…/64 $ ping -c3 10.10.10.1 # gateway → 0% loss ✓ $ ping -c3 1.1.1.1 # internet → 0% loss ✓ $ ping -c3 google.com # DNS → resolved ✓ $ ping -c3 10.10.20.1 # cross-VLAN → 0% loss ✓ The pfSense admin page also loaded in the browser from this zone. Note the cross-VLAN ping to IoT succeeding — that\u0026rsquo;s Trusted\u0026rsquo;s allow-any rule at work, and it sets up the contrast for the next test.\nIoT (VLAN 20) — internet only The same client then tagged into VLAN 20. Outbound internet and DNS still worked, but every internal path was refused:\n$ ip -br a show ens33.20 ens33.20@ens33 UP 10.10.20.100/24 fe80::…/64 $ ping -c3 1.1.1.1 # internet → 0% loss ✓ $ ping -c3 google.com # DNS → resolved ✓ $ ping -c3 10.10.10.1 # → Trusted → 100% loss ✗ blocked $ ping -c3 10.10.20.1 # own gateway → 100% loss ✗ blocked $ browse https://10.10.20.1 # admin → times out ✗ The gateway-ping failing while the internet still works is expected and correct: pinging the gateway is traffic to the firewall (blocked by rule 2), whereas reaching the internet is traffic through it (allowed by rule 4). A router forwards you; it doesn\u0026rsquo;t owe you a ping reply.\nTest Trusted (10) IoT (20) Guest (30) DHCP lease ✓ ✓ configured Internet ✓ ✓ ✓ by rule parity DNS resolution ✓ ✓ ✓ by rule parity Reach another zone ✓ ✗ blocked ✗ blocked Ping own gateway ✓ ✗ blocked ✗ blocked Firewall admin GUI ✓ ✗ blocked ✗ blocked Guest (VLAN 30) was built identically to IoT; its behaviour follows from the same ruleset (rule parity).\nTroubleshooting — four things that didn\u0026rsquo;t go to plan The parts that didn\u0026rsquo;t go to plan — and how I diagnosed them.\n1 · The console wizard trap Adding the trunk NIC triggered the firewall\u0026rsquo;s console interface-assignment wizard. That wizard only re-captures the interfaces it can auto-detect — which would have silently dropped my existing VPN interface from the assignment list. I skipped it deliberately and did all interface work in the web GUI instead, keeping the additive promise intact.\n2 · \u0026ldquo;Alias entries must be a single host or alias\u0026rdquo; The private-network alias was rejected when I first used it in a rule, because the destination field was set to Network (which expects a typed subnet) rather than Address or Alias (which accepts an alias name). A one-dropdown fix — but a good reminder that an alias is an object reference, not a subnet literal.\n3 · Source: address vs subnets I initially built several rules with a source of \u0026ldquo;interface address\u0026rdquo; (the firewall\u0026rsquo;s single IP on that VLAN) instead of \u0026ldquo;interface subnets\u0026rdquo; (the whole client network). Since client devices never send from the firewall\u0026rsquo;s own address, those rules matched nothing — which would have made IoT appear dead and left Guest wide open. Correcting every source to the subnet form fixed both at once. Lesson banked: source is almost always the subnet.\n4 · The big one: nothing crossed the virtual switch The hardest problem: with everything apparently configured correctly, the client could not pull a DHCP lease on any VLAN. Rather than guess, I isolated the fault layer by layer:\nClient side: the VLAN sub-interface was up and tagging correctly (confirmed with ip -d link) — so the client was fine. Firewall side: all three VLAN interfaces showed up with correct IPs — so the config was fine. But their inbound packet counters read zero: the firewall was hearing nothing. Cross-check: even untagged pings across the virtual network failed. When nothing crosses at all — tagged or untagged — the fault is the switch fabric itself, not the VLANs. Root cause. I\u0026rsquo;d opened the VMware Virtual Network Editor in read-only mode. Every earlier \u0026ldquo;re-apply\u0026rdquo; of the host-only network looked like it worked, but the Apply button was inactive — so the underlying virtual switch was never actually rebuilt. Clicking Change Settings to elevate to admin, then toggling and re-applying the network, rebuilt the switch. The DHCP lease landed on the very next attempt.\nThe lesson generalises well beyond this bug: a healthy-looking config on both endpoints doesn\u0026rsquo;t prove the medium between them works. Testing the untagged path was what pointed past the VLANs to the switch.\nOutcome The finished lab is a firewall enforcing genuine three-way isolation, verifiable on demand: a single client machine can drop onto any zone by changing one VLAN tag and observe exactly the access that zone\u0026rsquo;s rules permit. Blocked traffic can be watched live in the firewall logs as it\u0026rsquo;s dropped.\nVLANs \u0026amp; 802.1Q trunking — tagging, a shared trunk, per-VLAN subnets and DHCP. Inter-VLAN routing \u0026amp; firewalling — router-on-a-stick with default-deny, stateful, ordered rules. Firewall rule design — reusable aliases, DNS-before-block ordering, least-privilege zones. Systematic troubleshooting — isolating a fault across client, firewall, and switch layers instead of guessing. Virtualisation — recreating a full managed-switch topology, and a real desktop client, with zero physical hardware. Every concept here — trunking, tagging, inter-VLAN routing, zone-based firewalling — is exactly what runs on physical enterprise gear. The only difference is that the switch, the cabling, and the test PCs were all software. The design, and the isolation it enforces, are the real thing.\n","permalink":"https://billalrehmani.pages.dev/posts/vlan-segmentation-pfsense/","summary":"\u003cp\u003eA flat network trusts every device on it equally. A smart plug, a guest\u0026rsquo;s phone, and the machine holding my important data all share one space — so if any one of them is compromised, the attacker can reach the rest. \u003cstrong\u003eNetwork segmentation\u003c/strong\u003e breaks that flat space into separate zones and controls what may cross between them, shrinking the blast radius of any single compromise.\u003c/p\u003e\n\u003cp\u003eI wanted to build that properly — VLANs and a firewall — on VMware Workstation with pfSense CE, and design three zones at deliberately different trust levels:\u003c/p\u003e","title":"Virtual VLAN Segmentation on pfSense — Three Isolated Zones, No Managed Switch"},{"content":"My lab is deliberately isolated — an automation VM (CLAUDDEB) sits behind a virtual pfSense firewall on a segment (10.10.0.0/24) that my home network can\u0026rsquo;t reach. That isolation is great until you\u0026rsquo;re out of the house and want to check your Grafana dashboards, which only listen inside that segment.\nI already use Tailscale for casual remote access, and I\u0026rsquo;ll be honest up front: for pure convenience, Tailscale wins — it punches through NAT automatically with zero firewall work. But this project wasn\u0026rsquo;t about convenience. It was about building the thing Tailscale is made of. Tailscale is WireGuard under the hood; hand-rolling raw WireGuard on pfSense teaches you how VPNs actually work — keys, peers, routing, firewall rules, NAT — at a level the managed tool deliberately hides. So I built it from scratch, kept Tailscale as my daily driver, and got a genuinely brutal debugging lesson in the process.\nThe goal, and the one hard constraint Reach Grafana (and the rest of the lab) from my phone on mobile data, over a WireGuard tunnel terminating on my lab pfSense — the inner firewall. The catch is the topology: that pfSense\u0026rsquo;s \u0026ldquo;WAN\u0026rdquo; isn\u0026rsquo;t the internet, it\u0026rsquo;s my home LAN (192.168.1.x), bridged in from a VMware VM. So an inbound connection from my phone has to cross two firewalls:\nPhone (mobile data) │ UDP 51820 to my home\u0026#39;s public IP ▼ Home ISP router ── port-forward ──► pfSense WAN (192.168.1.189) │ ▼ Lab segment (10.10.0.x — CLAUDDEB, Grafana) phone gets a VPN address on 10.20.20.x and is routed into the lab Before anything else I checked I wasn\u0026rsquo;t behind CGNAT (carrier-grade NAT) — if the ISP shares one public IP across customers, inbound WireGuard is impossible without a relay. My router\u0026rsquo;s WAN showed a real public IP matching \u0026ldquo;what\u0026rsquo;s my IP,\u0026rdquo; so I was clear to port-forward.\nThe build Dynamic DNS first. My public IP is dynamic, so a hard-coded IP in the client would break on the next lease change. I set up DuckDNS via pfSense\u0026rsquo;s Custom DDNS type (DuckDNS isn\u0026rsquo;t a built-in provider), using an update URL with an empty \u0026amp;ip= so DuckDNS auto-detects the public IP rather than pfSense\u0026rsquo;s private WAN address — a neat fix for the nested-network wrinkle.\nThe WireGuard tunnel. Installed the WireGuard package, created a tunnel on UDP 51820 with the VPN subnet 10.20.20.1/24 — deliberately distinct from the lab (10.10.0.x) and home (192.168.1.x) networks, because overlapping subnets are a classic VPN foot-gun.\nThe peer (my phone). I generated the keypair in the phone\u0026rsquo;s WireGuard app so the private key never leaves the device, and pasted only its public key into pfSense. Assigned the phone the VPN address 10.20.20.3, added the generated pre-shared key for an extra layer, and set a 25-second keepalive so mobile NAT doesn\u0026rsquo;t drop the tunnel.\nThe port-forward. On the home router: forward UDP 51820 to pfSense\u0026rsquo;s WAN IP. I also set a DHCP reservation so pfSense\u0026rsquo;s WAN never wanders off 192.168.1.189 — and had to reserve it against pfSense\u0026rsquo;s actual VMware MAC (00:0c:29:…), not the host desktop\u0026rsquo;s, since the bridged VM is a separate device on the network.\nFirewall rules. Two on pfSense: a WAN rule allowing UDP 51820 in, and a rule on the WireGuard interface allowing the tunnel traffic onward. Then enabled the service.\nThe phone. Filled in the client — addresses 10.20.20.3/32, the server\u0026rsquo;s public key + pre-shared key, endpoint set to the DDNS hostname, and — importantly — Allowed IPs of 10.10.0.0/24, 10.20.20.0/24 so lab traffic routes into the tunnel (split-tunnel; normal browsing stays direct).\nSwitched the phone to mobile data, toggled the tunnel on, and:\nA handshake. The tunnel was up from the outside. I figured I was done.\nI was not done.\nThe four-layer debug Grafana wouldn\u0026rsquo;t load. Not on the phone, not from my host machine. What followed was the most instructive part of the whole project: four independent failures, each with the exact same symptom — \u0026ldquo;connection timed out\u0026rdquo; — but a completely different root cause at a completely different layer. The only way through was to stop guessing and read the actual packets.\nWall 1 — the tunnel wasn\u0026rsquo;t a routed interface pfSense\u0026rsquo;s own login (10.10.0.1) loaded over the tunnel, but no other lab host did. The difference: reaching pfSense terminates at the firewall; reaching CLAUDDEB requires pfSense to route tunnel traffic onward into the LAN. I\u0026rsquo;d never assigned the WireGuard tunnel as a pfSense interface — so it existed, but pfSense wouldn\u0026rsquo;t route from it. Assigning it (Interfaces → Assignments) and adding a pass rule on its new interface tab fixed the routing\u0026hellip; but it still didn\u0026rsquo;t load.\nWall 2 — the return path Time to read packets. On CLAUDDEB, tcpdump showed the phone\u0026rsquo;s SYN arriving — but no SYN-ACK going back. The request reached the box; the reply vanished. Classic asymmetric routing. I added an Outbound NAT rule so the traffic would appear to come from pfSense\u0026rsquo;s LAN IP (a directly-reachable neighbour), which CLAUDDEB could reply to cleanly. Progress — but still no load.\nWall 3 — a silent host firewall Back to tcpdump. Now the SYN arrived, but CLAUDDEB sent nothing back — not even a reset. A silent drop is the signature of a firewall.\nufw status said inactive — but that\u0026rsquo;s just a frontend. Checking the raw ruleset revealed nftables with a policy drop on the input chain, allowing only a handful of ports, and only over the Tailscale interface. My Grafana port (3000) from the LAN/VPN wasn\u0026rsquo;t permitted, so the kernel dropped every SYN before Grafana ever saw it.\nTwo nft rules allowing TCP 3000 from the lab and VPN subnets (made permanent in /etc/nftables.conf), and now tcpdump finally showed CLAUDDEB replying with SYN-ACK. So close. Still hanging.\nWall 4 — the missing return route The tcpdump told the final story: CLAUDDEB\u0026rsquo;s SYN-ACK (destined for 10.20.20.3) was leaving the box, but the phone never received it and kept resending its SYN. The reply was reaching pfSense and dying there. Checking pfSense\u0026rsquo;s routing table: there was no route for 10.20.20.0/24 at all. pfSense received the reply, couldn\u0026rsquo;t find a route to the VPN subnet, and dumped it out the default route (the WAN) into the void.\nThe fix was almost anticlimactic: the WireGuard interface had been assigned with no IP (IPv4 = None). Giving it the tunnel address 10.20.20.1/24 made pfSense install the connected route automatically.\nReloaded on the phone. Grafana\u0026rsquo;s login appeared.\nWhy this was worth it Every one of those four walls said \u0026ldquo;connection timed out.\u0026rdquo; Same symptom, four different layers:\npfSense — tunnel not assigned as a routable interface NAT — asymmetric return path CLAUDDEB — a hidden nftables policy drop eating the SYN Routing — no return route for the VPN subnet, so replies went out the WAN You cannot guess your way through that. The only thing that worked was reading tcpdump — SYN vs SYN-ACK, which source IP, reply or silence — and the pfSense firewall logs — pass vs block. Each capture pointed at exactly one layer. That evidence-driven, layer-by-layer approach is how real network debugging works, and it\u0026rsquo;s a far better story than a build that worked first try.\nSecurity notes The only inbound opening on the home router is a single UDP port-forward — no DMZ, nothing else exposed. WireGuard is key-based with an added pre-shared key; the phone\u0026rsquo;s private key never left the phone. Grafana ended up bound to all interfaces so the VPN could reach it — which is fine, because port 3000 isn\u0026rsquo;t forwarded on the router, so it\u0026rsquo;s only reachable from inside the lab or through the authenticated tunnel. I\u0026rsquo;ve redacted my public IP from the screenshots here; there\u0026rsquo;s no reason to publish the exact address and port your VPN listens on. Tailscale vs. this To be clear-eyed: Tailscale would have done all of this in ten minutes with none of the CGNAT checks, port-forwarding, DDNS, or firewall rules — because it solves NAT traversal for you. The point of building raw WireGuard wasn\u0026rsquo;t to replace it. It was to understand, hands-on, the machinery Tailscale abstracts away — and to run a VPN that depends on no third-party control plane. Tailscale is the automatic transmission; this was learning to drive stick. For a networking/security portfolio, knowing the manual matters.\nWhat\u0026rsquo;s next With remote access into the lab sorted, the natural next step is turning the monitoring stack into a security monitoring stack — Suricata IDS on pfSense, its alerts surfaced in Grafana. But that\u0026rsquo;s the next post. This one gets to end on a hard-won 200 OK.\n","permalink":"https://billalrehmani.pages.dev/posts/wireguard-pfsense-nested-firewall/","summary":"\u003cp\u003eMy lab is deliberately isolated — an automation VM (CLAUDDEB) sits behind a virtual pfSense firewall on a segment (\u003ccode\u003e10.10.0.0/24\u003c/code\u003e) that my home network can\u0026rsquo;t reach. That isolation is great until you\u0026rsquo;re out of the house and want to check your \u003ca href=\"/posts/prometheus-grafana-observability-stack/\"\u003eGrafana dashboards\u003c/a\u003e, which only listen inside that segment.\u003c/p\u003e\n\u003cp\u003eI already use \u003cstrong\u003eTailscale\u003c/strong\u003e for casual remote access, and I\u0026rsquo;ll be honest up front: for pure convenience, Tailscale wins — it punches through NAT automatically with zero firewall work. But this project wasn\u0026rsquo;t about convenience. It was about \u003cstrong\u003ebuilding the thing Tailscale is made of.\u003c/strong\u003e Tailscale \u003cem\u003eis\u003c/em\u003e WireGuard under the hood; hand-rolling raw WireGuard on pfSense teaches you how VPNs actually work — keys, peers, routing, firewall rules, NAT — at a level the managed tool deliberately hides. So I built it from scratch, kept Tailscale as my daily driver, and got a genuinely brutal debugging lesson in the process.\u003c/p\u003e","title":"Self-Hosted WireGuard Through a Nested Firewall — and the Four-Layer Debug to Make It Work"},{"content":"My Debian automation VM already sits behind a pfSense firewall with egress containment — it can reach the internet but not my home network. So why harden the VM itself? Because \u0026ldquo;behind a firewall\u0026rdquo; is doing less work than it sounds. Two paths reach into the VM without ever crossing pfSense, and an honest audit of my own box turned up drift I didn\u0026rsquo;t expect.\nThis is the write-up: what the audit found, what I changed, and the systemd sandbox mistake that quietly broke a service.\npfSense doesn\u0026rsquo;t see everything The containment rules stop the VM reaching out to the home network. But two channels bypass pfSense entirely on the way in:\nThe Tailscale tunnel. Any service bound to 0.0.0.0 is reachable from every device on my tailnet — the encrypted tunnel rides out through allowed egress and back in, invisible to pfSense rules. Today that\u0026rsquo;s just my laptop; if the tailnet or that laptop were compromised, it\u0026rsquo;s a direct line to every open port. The VMware host-only segment. The Windows host has an adapter on the same host-only network as the VM, so it talks straight to the VM\u0026rsquo;s listeners with no pfSense in the path. Host malware would have the same reach. The shared folders are a second host↔VM channel with the same trust implication. So the perimeter isn\u0026rsquo;t the whole story. The VM needs its own posture — the same least-privilege logic the lab already runs on, applied one layer in.\nThe audit — including my own drift I started read-only: what\u0026rsquo;s actually listening, what\u0026rsquo;s patched, what\u0026rsquo;s running. It was not all good news.\nFinding Risk 79 pending security updates, no automatic updates Known CVEs sitting unpatched — the biggest single issue Grafana listening on all interfaces My own earlier post says it\u0026rsquo;s localhost-bound; Grafana\u0026rsquo;s default is 0.0.0.0, so it wasn\u0026rsquo;t. Reachable from tailnet + host Apache serving on *:80 A leftover install — the blog deploys to GitHub Pages, nothing local needs it Docker running, 0 containers, user in docker group The docker group is effectively passwordless root (mount the host FS, become root) — attack surface for nothing in use MCP hub on 0.0.0.0:8420, no auth Any tailnet/host device could read and write its inbox No host firewall (ufw and nftables both disabled) Nothing filtering inbound at the host level at all CUPS on localhost, SSH on all interfaces Minor / expected, but worth tightening The Grafana one is the point of auditing: I\u0026rsquo;d written that it was localhost-only and believed it. The box disagreed.\nWhat I changed Patch, and keep patching The updates first, then automate them so this never drifts again:\napt update \u0026amp;\u0026amp; apt full-upgrade apt install unattended-upgrades # /etc/apt/apt.conf.d/20auto-upgrades APT::Periodic::Update-Package-Lists \u0026#34;1\u0026#34;; APT::Periodic::Unattended-Upgrade \u0026#34;1\u0026#34;; Cut the attack surface at the source Rather than firewall around exposed services, I stopped exposing them:\nGrafana → localhost. A drop-in setting GF_SERVER_HTTP_ADDR=127.0.0.1, matching the design I\u0026rsquo;d claimed. Apache, Docker, CUPS → disabled. All unused. Disabling Docker also removed the root-equivalent docker group risk (and, with zero containers, cost nothing). Reversible — a systemctl enable away if a future project needs them. MCP hub → bound to the Tailscale IP, not 0.0.0.0. It stays reachable from the laptop over the tailnet but disappears from the host-only segment. A host firewall, default-deny inbound Defense-in-depth against both bypass paths, and insurance against the next \u0026ldquo;oops, bound to 0.0.0.0\u0026rdquo;. SSH and the MCP hub are allowed only on the Tailscale interface:\ntable inet filter { chain input { type filter hook input priority 0; policy drop; iif \u0026#34;lo\u0026#34; accept ct state established,related accept ct state invalid drop ip protocol icmp accept ip6 nexthdr ipv6-icmp accept udp sport 67 udp dport 68 accept # DHCP client iifname \u0026#34;tailscale0\u0026#34; tcp dport { 22, 8420 } accept } chain forward { type filter hook forward priority 0; policy drop; } chain output { type filter hook output priority 0; policy accept; } } Because I ran this from the VM\u0026rsquo;s own console (not an SSH session), there was no lock-out risk — and the desktop console is always a fallback even if a rule is wrong.\nSSH and brute-force protection I have no SSH keys set up yet, so disabling password auth would have locked me out. Instead: root login off, tighter limits, and fail2ban to blunt brute force while password auth stays on. The firewall already restricts SSH to the tailnet, so the exposure is small.\n# /etc/ssh/sshd_config.d/99-hardening.conf PermitRootLogin no MaxAuthTries 3 LoginGraceTime 30 X11Forwarding no (Setting up key auth, then flipping PasswordAuthentication no, is the next step.)\nSandbox the custom services My own long-running daemons (the LaMetric pusher, the MCP hub) got systemd sandboxing — cheap containment if any is ever exploited:\n[Service] NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict ProtectHome=read-only ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes RestrictSUIDSGID=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX The hub writes to its inbox, so it also gets ReadWritePaths=\u0026lt;hub-dir\u0026gt; to carve write access back for that one path under the otherwise read-only home.\nTroubleshooting: the sandbox that broke the bind Applying all of the above, the MCP hub came back up — bound to 127.0.0.1, not the Tailscale IP. So the laptop couldn\u0026rsquo;t reach it.\nThe cause was a collision between two of my own changes. The hub detects its Tailscale IP at startup by shelling out to ip addr show tailscale0. But ip talks to the kernel over a netlink socket (AF_NETLINK) — and my sandbox\u0026rsquo;s RestrictAddressFamilies line only allowed AF_INET, AF_INET6, and AF_UNIX. Netlink was blocked, ip failed, and the detection fell back to localhost.\nThe fix was to detect the IP a different way. tailscale ip -4 asks the local tailscaled over a Unix socket (AF_UNIX) — which the sandbox does allow:\nfor cmd in ([\u0026#34;tailscale\u0026#34;, \u0026#34;ip\u0026#34;, \u0026#34;-4\u0026#34;], # AF_UNIX — allowed [\u0026#34;ip\u0026#34;, \u0026#34;-4\u0026#34;, \u0026#34;-o\u0026#34;, \u0026#34;addr\u0026#34;, \u0026#34;show\u0026#34;, \u0026#34;tailscale0\u0026#34;]): # AF_NETLINK — blocked ... Prefer the socket that survives the sandbox. A tidy reminder that hardening and functionality can trip over each other — tighten a restriction and something two steps away quietly changes behaviour instead of erroring loudly.\nWhere it landed 0 pending security updates, and automatic updates keep it there. Grafana on localhost; Apache, Docker and CUPS gone; the MCP hub on the tailnet only. A default-deny host firewall with SSH and the hub reachable only over Tailscale. Every remaining listener is either localhost or gated to the tailnet by the firewall — the 0.0.0.0:22 socket looks exposed, but the firewall only permits it on tailscale0. The two custom daemons run sandboxed. None of this replaces the pfSense containment — it complements it. The firewall assumes the perimeter can be bypassed (because two paths do), and the sandboxing assumes a service can be popped. Layers, each doing its own job.\nWhat\u0026rsquo;s next SSH key auth, then password auth off entirely. A bearer token on the MCP hub and a Tailscale ACL limiting port 8420 to the one device that needs it — auth on top of the network controls. A periodic re-audit, because the Grafana finding proved the gap between \u0026ldquo;what I documented\u0026rdquo; and \u0026ldquo;what the box is doing\u0026rdquo; is real. The honest check is the one worth repeating. ","permalink":"https://billalrehmani.pages.dev/posts/hardening-debian-homelab-vm/","summary":"\u003cp\u003eMy Debian automation VM already sits behind a pfSense firewall with egress containment — it can reach the internet but not my home network. So why harden the VM itself? Because \u0026ldquo;behind a firewall\u0026rdquo; is doing less work than it sounds. Two paths reach \u003cem\u003einto\u003c/em\u003e the VM without ever crossing pfSense, and an honest audit of my own box turned up drift I didn\u0026rsquo;t expect.\u003c/p\u003e\n\u003cp\u003eThis is the write-up: what the audit found, what I changed, and the systemd sandbox mistake that quietly broke a service.\u003c/p\u003e","title":"Hardening My Debian Home-Lab VM — Even Behind pfSense"},{"content":"I run Claude Code on two machines: the Debian VM in my home lab (always on) and a Debian laptop (sleeps, roams, follows me to campus). I wanted the laptop to push notes, facts, and findings into a central store on the VM — from any network — so the home-lab agent could pick them up later.\nClaude Code\u0026rsquo;s built-in Remote Control turns a second device into a remote window onto one session. That\u0026rsquo;s not what I wanted. I wanted both machines to stay fully independent agents, linked through a shared tool. So I built a small MCP server on the VM and pointed the laptop\u0026rsquo;s Claude Code at it: the hub becomes just another tool the laptop can call.\nGoals Let the laptop\u0026rsquo;s agent send notes to a central point on the home-lab VM. Make it work from any network — home LAN, campus Wi-Fi, hotspot — without opening inbound ports on the home firewall. Preserve the VM\u0026rsquo;s existing pfSense RFC1918 containment. Keep the server\u0026rsquo;s capabilities deliberately narrow. The moving parts MCP (Model Context Protocol) — an open protocol that lets Claude connect to external tools. An MCP server exposes capabilities as \u0026ldquo;tools\u0026rdquo;; an MCP client (Claude Code) discovers and invokes them mid-conversation. My hub exposes exactly two: push_info (write a note) and get_recent (read notes back). FastMCP (mcp Python SDK) — builds the server in ~40 lines. SSE (Server-Sent Events) — the transport. The default stdio transport only works for a process on the same machine; SSE runs over HTTP, so a remote machine can connect. Tailscale — a WireGuard mesh VPN. Every enrolled device gets a permanent 100.x.x.x private IP that works identically on any network, and all connections are outbound-only to Tailscale\u0026rsquo;s coordination servers — so no inbound firewall port is ever opened. systemd — runs the server as a persistent, auto-restarting service, the same pattern as my other lab daemons. Build process Each layer was verified before adding the next.\nMCP server on the hub VM. A project directory with a Python venv (Debian blocks system-wide pip) and the mcp SDK. The FastMCP server exposes the two tools: push_info(source, content) appends a timestamped entry to an append-only JSONL inbox (one JSON object per line, so concurrent writes never clobber each other), and get_recent(n) returns the last n entries. Bound to 0.0.0.0:8420 — it has to listen on the Tailscale interface, not just localhost — over SSE.\nPersistence via systemd. A service unit pointing at the venv\u0026rsquo;s Python and the server script, with Restart=always, ordered after networking and the Tailscale daemon, then systemctl enable --now.\nMesh VPN with Tailscale. Installed on both machines, enrolled under the same account (same account = same tailnet). The VM\u0026rsquo;s containment rules needed no changes — Tailscale is outbound-only, matching the existing model. Recorded each machine\u0026rsquo;s permanent tailnet IP.\nRegistering the hub with Claude Code. On the laptop:\nclaude mcp add --transport sse --scope user desktop-hub http://\u0026lt;hub-tailnet-ip\u0026gt;:8420/sse --scope user registers the server account-wide rather than per-directory. The same command pointed at localhost on the VM, so its own Claude Code can read the inbox too.\nEnd-to-end verification. A laptop session called push_info with a test note; the hub confirmed storage. I verified it on the VM both by reading the inbox file directly and via the VM\u0026rsquo;s own Claude Code calling get_recent — the screenshot above.\nTroubleshooting log The instructive part:\nSymptom Root cause Fix status=203/EXEC, service died in 34 ms ExecStart/User= referenced the wrong username and home path — systemd couldn\u0026rsquo;t find the binary Corrected the paths, daemon-reload, restart Venv Python \u0026ldquo;No such file or directory\u0026rdquo; python3 -m venv had silently never completed — only server.py existed Re-ran venv creation (installing python3-venv) and reinstalled the SDK Service still failing after the venv fix The edited paths hadn\u0026rsquo;t been saved — status still showed the old ones Checked the Process: line in systemctl status to spot the stale path; re-edited and restarted Tailnet ping 0.05–0.12 ms, but curl returned nothing I was pinging the laptop\u0026rsquo;s own tailnet IP (loopback-level latency was the tell), not the VM\u0026rsquo;s tailscale status lists every device\u0026rsquo;s IP — used the VM\u0026rsquo;s Laptop\u0026rsquo;s Claude Code couldn\u0026rsquo;t see the hub The server was added after the session started (MCP servers load at session start), and default scope is per-directory Re-added with --scope user, started a fresh session Note \u0026ldquo;not found\u0026rdquo; on the VM I looked for a visible artefact, not the inbox file — and the VM\u0026rsquo;s Claude Code had no MCP registration (hosting a server ≠ being its client) Read the JSONL inbox directly; separately registered the hub with the VM\u0026rsquo;s Claude Code via localhost Diagnostics worth keeping: journalctl -u \u0026lt;service\u0026gt; for logs; the Process: line in systemctl status shows the actual command systemd tried; abnormally low ping latency means you\u0026rsquo;re talking to yourself; and claude mcp list from the shell versus /mcp inside a session are different surfaces.\nSecurity posture Minimal tool surface. The hub exposes only note-write and note-read — no shell execution, no arbitrary filesystem access. Every tool on an MCP server is invocable by any connected client, so a generic \u0026ldquo;run command\u0026rdquo; tool would turn a compromised client into a remote shell. Capabilities stay deliberately narrow. No inbound exposure. Tailscale\u0026rsquo;s outbound-only model means the home firewall never opens a port; the hub is unreachable from the public internet. Containment preserved. The VM\u0026rsquo;s RFC1918 isolation rules were untouched throughout. Input-sanitisation pattern. Any future tool touching the filesystem should strip path components from inputs (os.path.basename()) to block directory traversal. Architecture ┌─────────────────────┐ Tailscale mesh ┌──────────────────────┐ │ Laptop (roaming) │ ── outbound WireGuard/DERP ──▶ │ Home-lab VM (hub) │ │ Claude Code agent │ │ behind pfSense │ │ MCP client │ http://\u0026lt;tailnet-ip\u0026gt;:8420/sse │ FastMCP server :8420 │ │ │ ──── push_info / get_recent ──▶│ inbox.jsonl store │ └─────────────────────┘ └──────────────────────┘ The design is symmetric in principle — the laptop could host and the VM could be the client — but hub-and-spoke was deliberate: the VM is always on, while the laptop sleeps and roams, which makes it a poor server.\nWhere this goes The recipe — FastMCP + SSE + Tailscale + systemd — reapplies to any service that needs secure remote reachability without punching holes in the home network. The hub is extensible: new narrowly-scoped tools (constrained file drops, status queries, task queues) are each just a decorated Python function plus a service restart.\nNext steps I\u0026rsquo;ve left open: token-based authentication in front of the server, Tailscale ACLs restricting which devices may reach port 8420, and swapping the JSONL inbox for SQLite if querying needs grow. The theme is the same one running through the whole lab — connect outward to a rendezvous point, keep the capabilities minimal, and never open an inbound door.\n","permalink":"https://billalrehmani.pages.dev/posts/claude-code-mcp-hub-tailscale/","summary":"\u003cp\u003eI run Claude Code on two machines: the Debian VM in my home lab (always on) and a Debian laptop (sleeps, roams, follows me to campus). I wanted the laptop to push notes, facts, and findings into a central store on the VM — from any network — so the home-lab agent could pick them up later.\u003c/p\u003e\n\u003cp\u003eClaude Code\u0026rsquo;s built-in Remote Control turns a second device into a remote \u003cem\u003ewindow\u003c/em\u003e onto one session. That\u0026rsquo;s not what I wanted. I wanted both machines to stay fully independent agents, linked through a shared tool. So I built a small \u003cstrong\u003eMCP server\u003c/strong\u003e on the VM and pointed the laptop\u0026rsquo;s Claude Code at it: the hub becomes just another tool the laptop can call.\u003c/p\u003e","title":"Syncing Claude Code Across Devices with a Custom MCP Hub over Tailscale"},{"content":"My LaMetric display gives an at-a-glance read on the lab, but it\u0026rsquo;s a spot reading with no history — good for \u0026ldquo;is something on fire right now,\u0026rdquo; useless for \u0026ldquo;what happened overnight.\u0026rdquo; This project adds the layer underneath: a Prometheus + Grafana stack that scrapes my hosts continuously, stores the history, and draws real dashboards.\nTwo targets: CLAUDDEB (my Debian automation VM) and my pfSense firewall, reusing the exact SNMP setup from the pfSense post — just pointed at something far more capable than a 37-pixel display.\nWhere it runs, and why The whole stack lives on CLAUDDEB, and that falls out of how Prometheus works. Prometheus is pull-based: it reaches out to each target and scrapes its metrics, rather than targets pushing to it. So placement is decided by reachability. My targets are node_exporter on CLAUDDEB itself (localhost) and pfSense (the gateway CLAUDDEB is already allowed to reach). Anywhere else, Prometheus would have to scrape across my network isolation into the lab segment — which the containment blocks. Running it inside the segment, next to the data, keeps everything self-contained with no new firewall holes.\nThe consequence: Grafana lives on localhost:3000, viewed from CLAUDDEB\u0026rsquo;s own desktop. Reaching it from another machine would be a separate, scoped decision (a firewall rule or a VPN), left for later.\nThe tools Prometheus (port 9090) — the core: a time-series database that scrapes targets on a schedule, stores the numbers over time, and answers queries in its own language, PromQL. node_exporter (port 9100) — exposes a host\u0026rsquo;s own metrics (CPU, memory, disk, network, filesystem) as a page of numbers for Prometheus to scrape. This is how CLAUDDEB\u0026rsquo;s health gets in. snmp_exporter (port 9116) — a translator. Prometheus speaks HTTP; pfSense speaks SNMP. The exporter sits between: Prometheus asks it over HTTP, it queries pfSense over SNMP (the same interface counters as last time), and hands back Prometheus-style metrics. Grafana (port 3000) — the dashboards. It stores nothing itself; it queries Prometheus on demand and draws the graphs. systemd — runs each of the above as a service that starts on boot, the same pattern as my LaMetric pusher. The build, in order Each piece got a locked-down service user (no home, no login), a systemd unit, and — importantly — was bound to localhost only. I verified each layer before adding the next.\nnode_exporter — installed, confirmed it served metrics at 127.0.0.1:9100/metrics. Prometheus — installed with a config listing what to scrape (itself + node_exporter to start), then checked its /targets page showed both UP. Prometheus\u0026rsquo;s own query UI confirmed the data was flowing before I built anything on top of it:\nsnmp_exporter — installed with its bundled config (which already includes an if_mib module for interface counters). One edit: the default config only knows the community public, so I added an auth block for my real read-only community. Tested directly with curl and got the pfSense interface counters back. Wired pfSense into Prometheus — the one genuinely odd bit. Scraping SNMP needs a relabel_configs block that reads as a redirect: Prometheus connects to the exporter (127.0.0.1:9116) but tells it to go query pfSense, and labels the results as pfSense\u0026rsquo;s. That indirection is the whole trick of SNMP-via-exporter. A third target, snmp-pfsense, then showed UP. Grafana — installed from its APT repo (so apt upgrade keeps it patched — worth it for anything with a login), bound to localhost, logged in, forced a new password. Connected and visualized — added Prometheus as a Grafana data source, then imported two prebuilt community dashboards by ID rather than building panels by hand: 1860 — Node Exporter Full, a comprehensive host dashboard. 11169 — an SNMP interface dashboard for the pfSense data. Both lit up with live data almost immediately. Importing by ID is a genuinely useful trick — grafana.com hosts thousands of these, and they \u0026ldquo;just work\u0026rdquo; when the metric names match your exporter.\nSecurity posture Nothing here is exposed, which was the point:\nEverything binds to 127.0.0.1. The exporters have no authentication — they hand their metrics to anyone who asks — so localhost binding is essential, not optional. Grafana does have a login, but I bound it to localhost anyway: a login page that isn\u0026rsquo;t reachable can\u0026rsquo;t be attacked. SNMP stays read-only with a non-default community, bound to pfSense\u0026rsquo;s LAN side only. Service users can\u0026rsquo;t log in or own files elsewhere — standard hardening for daemons that only need to run. The gotchas The parts that cost time:\nPrometheus 3.x dropped the old console templates. The install step to copy consoles/ and console_libraries/ failed because 3.x no longer ships them (deprecated in favour of Grafana). Harmless — nothing uses them, so skip it. A systemd unit choked on inline comments. I\u0026rsquo;d added comments after a line-continuation (\\) in ExecStart, and systemd threw Invalid unit name. In a systemd unit, comments must be on their own line — never trailing a value or a continuation. software-properties-common doesn\u0026rsquo;t exist under that name on Debian 13. It provides add-apt-repository, which I wasn\u0026rsquo;t using — I added Grafana\u0026rsquo;s repo by writing the source file directly. Prometheus briefly showed itself as down right after a restart — just the self-scrape not having run yet. It went green within 30 seconds. \u0026ldquo;Down\u0026rdquo; immediately after a restart usually means \u0026ldquo;not scraped yet,\u0026rdquo; not \u0026ldquo;broken.\u0026rdquo; The SNMP dashboard\u0026rsquo;s Uptime tile reads N/A. The if_mib module only exposes interface data, not system uptime — cosmetic, and throughput (the thing I care about) works perfectly. The result A proper monitoring backbone: node_exporter and snmp_exporter feeding Prometheus, visualized in two live Grafana dashboards — one for CLAUDDEB\u0026rsquo;s health, one for the firewall\u0026rsquo;s interface throughput, both building history every fifteen seconds.\nThe disk-usage panels alone would have flagged the near-full root filesystem that bit me earlier, long before it caused trouble. The layering now makes sense end to end: the LaMetric is the glance, Grafana is the deep-dive, and Prometheus is the memory underneath both.\nWhat\u0026rsquo;s next The capstone is closing the loop. Grafana has an alerting engine, so a threshold breach — WAN throughput spiking, disk creeping past 85% — could fire a warning frame back to the LaMetric over the same MQTT broker from the first project. That would turn four separate builds into one connected system: metrics → alerting → a physical light on my desk.\nThe broader lesson is simple: a glance is not monitoring. A display tells you the current value; observability tells you the trend, the history, and when something started. Both are useful, but only one lets you answer the question that actually matters at 2am — what changed?\n","permalink":"https://billalrehmani.pages.dev/posts/prometheus-grafana-observability-stack/","summary":"\u003cp\u003eMy \u003ca href=\"/posts/implementing-lametric-time-to-network/\"\u003eLaMetric display\u003c/a\u003e gives an at-a-glance read on the lab, but it\u0026rsquo;s a spot reading with no history — good for \u0026ldquo;is something on fire right now,\u0026rdquo; useless for \u0026ldquo;what happened overnight.\u0026rdquo; This project adds the layer underneath: a \u003cstrong\u003ePrometheus + Grafana\u003c/strong\u003e stack that scrapes my hosts continuously, stores the history, and draws real dashboards.\u003c/p\u003e\n\u003cp\u003eTwo targets: \u003cstrong\u003eCLAUDDEB\u003c/strong\u003e (my Debian automation VM) and my \u003cstrong\u003epfSense firewall\u003c/strong\u003e, reusing the exact SNMP setup from the \u003ca href=\"/posts/implementing-lametric-time-to-network-part-2/\"\u003epfSense post\u003c/a\u003e — just pointed at something far more capable than a 37-pixel display.\u003c/p\u003e","title":"Building a Prometheus and Grafana Observability Stack for My Home Lab"},{"content":"In part 1 I got a LaMetric Time showing live health from my home lab over MQTT, so it worked across my network isolation — CPU, memory, disk, uptime, and the automation VM\u0026rsquo;s own traffic, all from a single Debian box.\nUseful, but those were really that box\u0026rsquo;s stats. The frame the display was named for is my network\u0026rsquo;s throughput — the traffic crossing my firewall — and that data lives on pfSense, not the Debian box. This is the follow-up: pulling real WAN in/out rates off pfSense over SNMP and putting them on the display. It\u0026rsquo;s shorter than the MQTT build, because the pipeline already exists; all I\u0026rsquo;m adding is a new data source. Getting numbers out of pfSense is the part worth writing down.\nWhy this one is reachable My automation VM (CLAUDDEB) sits behind a virtual pfSense firewall that acts as its gateway and enforces RFC1918 containment rules, blocking it from the rest of my home network. The distinction that makes this project possible:\nCLAUDDEB can\u0026rsquo;t route across pfSense to other segments, but it can reach pfSense itself. Querying your own default gateway isn\u0026rsquo;t the same as routing through it to somewhere you\u0026rsquo;re not allowed. SNMP from the VM to the firewall\u0026rsquo;s LAN interface is fair game, even though the VM can\u0026rsquo;t reach the LaMetric on the guest network. The isolation blocks lateral movement; it doesn\u0026rsquo;t blind a host to its own gateway.\nThe tools SNMP — the standard way network gear exposes operational data (interface counters, uptime, system info) for polling. The read path into pfSense. pfSense\u0026rsquo;s SNMP service (bsnmpd) — the built-in daemon, toggled under Services → SNMP, that publishes the firewall\u0026rsquo;s stats. IF-MIB interface counters — the data I\u0026rsquo;m after: ifName (maps an interface name to its numeric SNMP index) and ifHCInOctets / ifHCOutOctets (the byte counters for traffic in and out). 64-bit (\u0026ldquo;HC\u0026rdquo;) counters — the original 32-bit octet counters wrap every few seconds on a fast link, wrecking any rate calculation. The 64-bit HC variants don\u0026rsquo;t realistically wrap, so the throughput math stays correct at speed. net-snmp client tools (snmpwalk, snmpget — sudo apt install snmp) — the collector shells out to these rather than pulling in a heavyweight Python SNMP library. Rock-solid and easy to test by hand. The MQTT pipeline, the Python publisher, and the systemd service from part 1 are reused unchanged — the new WAN data rides the same rails.\nConfiguring pfSense Entirely in the web UI, under Services → SNMP:\nEnable the service. Set a Community String — never leave it as public, the SNMP equivalent of a default password. I used a non-default, read-only name. Under Interface Binding, bind the daemon to LAN — the side the VM reaches. Never bind SNMP to WAN; the firewall should not answer SNMP queries from the internet. Make sure the MibII module is enabled — it carries the interface counters, and without it the ifHC* OIDs return nothing. Two more things:\nFirewall rule. Because my LAN rules are restrictive, I had to permit traffic from the VM to the pfSense LAN IP on UDP 161. If your SNMP test times out, this is the first thing to add. Identify the WAN interface. Interfaces → WAN shows the underlying device name in parentheses — mine is em0. SNMP identifies interfaces by index number, so pointing the collector at the wrong one would label LAN traffic as WAN. The collector The Python side does three things: resolve the WAN interface\u0026rsquo;s SNMP index by name, read its two HC octet counters a second apart, and turn the delta into Mbps. Resolving by name rather than a hard-coded index means it survives interface reshuffling.\ndef collect_pfsense(host, community, wan_iface, interval=1.0): result = {\u0026#34;reachable\u0026#34;: False, \u0026#34;wan_rx_mbps\u0026#34;: None, \u0026#34;wan_tx_mbps\u0026#34;: None} # sysUpTime doubles as a reachability probe uptime = _snmp([\u0026#34;snmpget\u0026#34;, \u0026#34;-v2c\u0026#34;, \u0026#34;-c\u0026#34;, community, \u0026#34;-Oqv\u0026#34;, \u0026#34;-t\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;-r\u0026#34;, \u0026#34;1\u0026#34;, host, _OID_SYSUPTIME]) result[\u0026#34;reachable\u0026#34;] = uptime is not None if not (wan_iface and result[\u0026#34;reachable\u0026#34;]): return result idx = _resolve_ifindex(host, community, wan_iface) # walk ifName, match, cache if not idx: return result in0 = _snmp_counter(host, community, f\u0026#34;{_OID_IFHCIN}.{idx}\u0026#34;) out0 = _snmp_counter(host, community, f\u0026#34;{_OID_IFHCOUT}.{idx}\u0026#34;) time.sleep(interval) in1 = _snmp_counter(host, community, f\u0026#34;{_OID_IFHCIN}.{idx}\u0026#34;) out1 = _snmp_counter(host, community, f\u0026#34;{_OID_IFHCOUT}.{idx}\u0026#34;) to_mbps = lambda d: max(d, 0) * 8 / 1e6 / interval result[\u0026#34;wan_rx_mbps\u0026#34;] = to_mbps(in1 - in0) result[\u0026#34;wan_tx_mbps\u0026#34;] = to_mbps(out1 - out0) return result The index lookup walks ifName (falling back to ifDescr), matches the configured interface, pulls the index off the end of the OID, and caches it so it only runs once. If SNMP is unreachable, every path returns None and the frame shows WAN DL ? instead of the service crashing. Configuration is three lines in the existing .env:\nPFSENSE_SNMP_HOST=\u0026lt;pfsense-lan-ip\u0026gt; PFSENSE_SNMP_COMMUNITY=\u0026lt;your-community\u0026gt; PFSENSE_WAN_IFACE=em0 The test that proves it After enabling SNMP and installing the client tools, the single most useful command walks the interface-name table — it does double duty: if it returns anything, SNMP, firewall and community are all working, and its output is the list of interface names needed to identify WAN.\nsnmpwalk -v2c -c \u0026lt;community\u0026gt; \u0026lt;pfsense-ip\u0026gt; 1.3.6.1.2.1.31.1.1.1.1 ...1.1 = STRING: \u0026#34;em0\u0026#34; \u0026lt;- physical ...1.2 = STRING: \u0026#34;em1\u0026#34; \u0026lt;- physical ...1.3 = STRING: \u0026#34;enc0\u0026#34; \u0026lt;- IPsec (virtual) ...1.4 = STRING: \u0026#34;lo0\u0026#34; \u0026lt;- loopback ...1.5 = STRING: \u0026#34;pflog0\u0026#34; \u0026lt;- pf logging ...1.6 = STRING: \u0026#34;pfsync0\u0026#34; \u0026lt;- state sync ...1.7 = STRING: \u0026#34;ovpns1\u0026#34; \u0026lt;- OpenVPN server Only em0 and em1 are real NICs; the rest are internal. The UI confirmed em0 is WAN, so that went into PFSENSE_WAN_IFACE.\nTo prove the counter reflects real WAN traffic before trusting the display, I ran two terminals on the VM — one generating a sustained download, the other polling em0\u0026rsquo;s raw in-octets counter every two seconds:\nCounter64: 2940747473 Counter64: 3004631778 Counter64: 3078975251 Counter64: 3156804718 Counter64: 3215876127 Each two-second step jumps ~64 million bytes. Do the arithmetic — 64 MB ÷ 2 s × 8 bits ≈ 256 Mbps of real WAN traffic, live off the firewall\u0026rsquo;s counter. That\u0026rsquo;s the moment it went from \u0026ldquo;I think this works\u0026rdquo; to \u0026ldquo;this works.\u0026rdquo;\nThe last step was on the LaMetric app itself: it was built for 9 frames, so it capped there and hid the two new WAN frames. Same fix as always — edit the app in DevZone, bump it to 11 frames, republish, and force the device to re-pull. Then WAN DL / WAN UL joined the rotation.\nThe gotchas The parts that cost time:\nRunning the wrong copy of the script. My first test showed the old reachability frame and no WAN numbers, because I\u0026rsquo;d dropped the updated script into a second folder while the service still ran the original. One project, one directory — and grep for a string only the new version has before trusting a test. Zero movement during a download — twice. The WAN frame sat at 0.0 Mb while I \u0026ldquo;downloaded something.\u0026rdquo; Two separate reasons: first the download was on a different machine, so it never crossed this firewall; then a real download on the VM finished between sample windows. The collector reads one second out of every sixty, so only sustained traffic registers — a small file is invisible. DNS in the contained environment. My first test download host wouldn\u0026rsquo;t resolve — outbound name resolution is limited by design. I used a Debian mirror the VM could already reach. Which WAN is this, really? The conceptual one. The pfSense my VM sits behind is my lab firewall — its \u0026ldquo;WAN\u0026rdquo; is the boundary of the isolated lab segment, not my household internet uplink. So this frame reports traffic crossing that boundary. For a lab display that\u0026rsquo;s arguably the more interesting number, but it\u0026rsquo;s important to know exactly what you\u0026rsquo;re looking at. A caveat worth stating Because it samples one second per minute, the WAN frame is a spot reading, not a continuous meter — bursts between samples don\u0026rsquo;t show up, and it should never be mistaken for traffic accounting. It answers \u0026ldquo;is the link busy right now?\u0026rdquo; at a glance, which is all it\u0026rsquo;s meant to do. For a true interval average, the cleaner approach is diffing the counters across the full 60-second publish cycle instead of a 1-second sub-sample — an easy change I haven\u0026rsquo;t needed.\nSecurity notes Nothing here opened an attack surface, which was the point:\nSNMP is read-only with a non-default community string, so at worst it leaks stats to something already on my LAN. The daemon is bound to LAN only — never exposed to the internet, which is the classic mistake. The VM querying its own gateway stays entirely inside the isolation model — no lateral reach, no firewall holes, no port-forwards. The result The display now cycles WAN DL and WAN UL frames showing real megabits per second in and out of my lab firewall, pulled over SNMP from a device the VM isn\u0026rsquo;t even allowed to route through — only to. Combined with the existing frames, it\u0026rsquo;s a genuine at-a-glance board for the whole segment: the VM\u0026rsquo;s own health on one side, the network boundary\u0026rsquo;s throughput on the other. The indicator app itself is published in the LaMetric store:\nThe broader lesson is small but useful: the data you want often isn\u0026rsquo;t on the box you\u0026rsquo;re standing on. SNMP is the boring, universal, decades-old answer to \u0026ldquo;let me read stats off that other device\u0026rdquo; — every switch, router, firewall and NAS speaks it. Once the WAN counters were flowing, the display finally lived up to its name.\n","permalink":"https://billalrehmani.pages.dev/posts/implementing-lametric-time-to-network-part-2/","summary":"\u003cp\u003eIn \u003ca href=\"/posts/implementing-lametric-time-to-network/\"\u003epart 1\u003c/a\u003e I got a LaMetric Time showing live health from my home lab over MQTT, so it worked across my network isolation — CPU, memory, disk, uptime, and the automation VM\u0026rsquo;s own traffic, all from a single Debian box.\u003c/p\u003e\n\u003cp\u003eUseful, but those were really \u003cem\u003ethat box\u0026rsquo;s\u003c/em\u003e stats. The frame the display was named for is my \u003cstrong\u003enetwork\u0026rsquo;s\u003c/strong\u003e throughput — the traffic crossing my firewall — and that data lives on pfSense, not the Debian box. This is the follow-up: pulling real WAN in/out rates off pfSense over SNMP and putting them on the display. It\u0026rsquo;s shorter than the MQTT build, because the pipeline already exists; all I\u0026rsquo;m adding is a new data source. Getting numbers \u003cem\u003eout of pfSense\u003c/em\u003e is the part worth writing down.\u003c/p\u003e","title":"Implementing LaMetric TIME to Network Part 2"},{"content":"I picked up a LaMetric Time — an 8x37 pixel smart display — and after locking it down on an isolated guest network, the next move was to make it useful: live health from my home lab. CPU, memory, network throughput — the numbers worth a glance.\nThe interesting part is that a constraint I\u0026rsquo;d deliberately built into my network dictated the whole architecture. This is the write-up: the design decision, the pipeline, and the gotchas — because the gotchas are the useful part.\nThe constraint that shaped everything The two relevant pieces of my lab:\nCLAUDDEB — a Debian 13 VM that does automation work, sitting behind a virtual pfSense firewall with RFC1918 rules that block it from the home LAN (that build has its own post). Outbound internet only; no inbound. The LaMetric — on an isolated guest SSID (WPA3, client isolation, per my home network setup). Outbound internet only. In one sentence: the box producing the data and the device displaying it sit on two networks that cannot reach each other on the LAN — by design. That isolation is a feature, and I wasn\u0026rsquo;t going to weaken it to show a CPU percentage.\nPicking the delivery method LaMetric indicator apps support four communication types. The tutorials all use Local Push — POST a JSON blob to the device\u0026rsquo;s IP — which requires exactly the LAN reachability my isolation forbids:\nMethod How it works Fits the isolation? Local Push You POST to the device IP on the LAN ❌ needs LAN reachability Poll Device fetches a URL on a schedule ⚠️ needs a hosted public URL MQTT Device subscribes to a broker; I publish to it ✅ both sides connect outbound Web Socket Device connects to a WS server ⚠️ needs a hosted WS endpoint MQTT was the clean winner. Both the LaMetric and CLAUDDEB open outbound connections to a broker on the internet and meet in the middle. Neither accepts an inbound connection; neither reaches the other on the LAN. The isolation stays fully intact, with near-real-time updates as a bonus.\nThe architecture CLAUDDEB (Debian, behind pfSense) LaMetric (isolated guest Wi-Fi) ──────────────────────────────── ────────────────────────────── psutil collectors subscribes to → build frames JSON labmetric/home/stats → paho-mqtt publish ──┐ ┌──► renders frames (TLS 8883, QoS1, │ │ retain=true) │ │ ▼ │ ┌─────────────────────────────┴──┐ │ HiveMQ Cloud broker │ │ (TLS, topic-based pub/sub) │ └─────────────────────────────────┘ outbound-only from both sides The pieces, briefly:\nHiveMQ Cloud (Serverless free tier) — the broker. TLS-only MQTT on port 8883; its web test client became the most useful debugging tool of the project. LaMetric DevZone — defines the on-device indicator app: communication type MQTT, TLS on, a topic, a subscribe-only credential, data format Predefined (LaMetric Format). paho-mqtt + psutil (Python, in a venv — Debian 13 enforces PEP 668) — collect the stats and publish the frames. systemd — runs the publisher as a persistent service; a long-lived MQTT connection is the idiomatic pattern. TLS — HiveMQ presents a publicly-trusted certificate, validated against Debian\u0026rsquo;s CA store with zero extra config. Credentials are least-privilege by design: a subscribe-only user on the LaMetric, a publish-only user in the script\u0026rsquo;s config. A leak of either can\u0026rsquo;t do the other side\u0026rsquo;s job.\nSecurity note: the real broker hostname is redacted to \u0026lt;cluster\u0026gt;.s1.eu.hivemq.cloud throughout. Publishing your cluster URL isn\u0026rsquo;t catastrophic, but there\u0026rsquo;s no reason to hand it out.\nThe publisher One Python script: collectors gather stats into dicts, a builder turns them into LaMetric frames, a publisher ships them. The frame builder (with a SKIP_FRAMES env var to drop unwanted frames and re-index the rest):\ndef build_frames(cfg): catalog = [] sysd = collect_system(cfg[\u0026#34;disk_path\u0026#34;]) catalog.append((\u0026#34;cpu\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;CPU {sysd[\u0026#39;cpu\u0026#39;]:.0f}%\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;cpu\u0026#34;]})) catalog.append((\u0026#34;ram\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;RAM {sysd[\u0026#39;ram\u0026#39;]:.0f}%\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;ram\u0026#34;]})) catalog.append((\u0026#34;disk\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;DISK {sysd[\u0026#39;disk\u0026#39;]:.0f}%\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;disk\u0026#34;]})) load1 = collect_load() if load1 is not None: catalog.append((\u0026#34;load\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;LOAD {load1:.2f}\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;load\u0026#34;]})) tp = collect_throughput(cfg[\u0026#34;net_skip\u0026#34;]) catalog.append((\u0026#34;rx\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;RX {tp[\u0026#39;rx_mbps\u0026#39;]:.1f}Mb\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;rx\u0026#34;]})) catalog.append((\u0026#34;tx\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;TX {tp[\u0026#39;tx_mbps\u0026#39;]:.1f}Mb\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;tx\u0026#34;]})) catalog.append((\u0026#34;conn\u0026#34;, {\u0026#34;text\u0026#34;: f\u0026#34;CONN {collect_connections()}\u0026#34;, \u0026#34;icon\u0026#34;: ICONS[\u0026#34;conn\u0026#34;]})) # ... net latency, uptime, optional pfSense ... frames = [] for i, (_key, frame) in enumerate(c for c in catalog if c[0] not in cfg[\u0026#34;skip\u0026#34;]): frames.append({\u0026#34;index\u0026#34;: i, **frame}) return frames Throughput is sampled as a one-second delta (loopback excluded), so the display reflects actual traffic. Established TCP connections are counted by parsing /proc/net/tcp directly — no root required. The publish uses QoS 1 and retain=True, so the device shows current values the moment it (re)subscribes:\ndef publish_frames(client, topic, frames, timeout=10.0): info = client.publish(topic, json.dumps({\u0026#34;frames\u0026#34;: frames}), qos=1, retain=True) info.wait_for_publish(timeout=timeout) return info.is_published() Secrets and tunables live in a git-ignored .env. The script has three modes: --dry-run (print frames, no network), --once (single publish), and --loop --interval N (what the service runs). The systemd unit points straight at the venv\u0026rsquo;s Python:\n[Service] Type=simple User=\u0026lt;user\u0026gt; WorkingDirectory=/home/\u0026lt;user\u0026gt;/labmetric-pusher ExecStart=/home/\u0026lt;user\u0026gt;/labmetric-pusher/.venv/bin/python \\ /home/\u0026lt;user\u0026gt;/labmetric-pusher/labmetric.py --loop --interval 60 Restart=always RestartSec=10 Test each leg in isolation The practice I\u0026rsquo;d most recommend for any multi-hop pipeline — three independent verification steps, so a failure always has an obvious home:\nCollection — --dry-run prints the frames JSON with no network. If the numbers are right, the collectors work. Publisher → broker — subscribe to the topic in HiveMQ\u0026rsquo;s web client, run --once, watch the JSON land. If it arrives, publishing, TLS and auth are all good. Broker → device — only now look at the physical display. The troubleshooting log The parts that cost time:\nHiveMQ\u0026rsquo;s console hides the cluster until one exists. Deploy via Deploy a new broker → Serverless, then Manage Cluster shows host and port. Port 8883 is TLS-only. The single most important checkbox in the LaMetric app config is \u0026ldquo;Use TLS\u0026rdquo; — without it the connection silently fails the handshake. Broker passwords can\u0026rsquo;t be recovered (hashed server-side). Recreate the credential. The LaMetric app form blanks its credentials on save — username, password, topic and the TLS checkbox come back empty while host/port survive. Re-check all four on every re-save. A \u0026ldquo;private\u0026rdquo; LaMetric app still requires a privacy-policy URL. Any URL that resolves passes validation — I pointed it at this blog\u0026rsquo;s privacy page. The .env.example dotfile didn\u0026rsquo;t copy — file managers hide dotfiles, so a drag-copy skipped it. The frame-count trap. The DevZone app must define the same number of frames as you publish. I expanded the script from 5 frames to 9 and the device silently kept showing 5, even though the broker held the full message. Fix: add frames in the app until the count matches, republish. The device caches the app for ~10 hours. After republishing, force an update via the app\u0026rsquo;s \u0026ldquo;i\u0026rdquo; action on the device, or remove and re-add the app. A privacy catch worth knowing Once live, the display started showing my home address. It wasn\u0026rsquo;t my pipeline — I\u0026rsquo;d just watched the full MQTT payload in the broker\u0026rsquo;s client, and it contained only stat frames. The culprit was LaMetric\u0026rsquo;s built-in Weather app, which geolocates during setup and was cycling alongside my app. These displays leak location by default, independently of anything you build; the fix is in the device\u0026rsquo;s location settings, not your code.\nThe result Nine frames cycle on the display — CPU, RAM, DISK, LOAD, RX, TX, CONN, NET latency, UPTIME — refreshed every 60 seconds by a systemd service, delivered over TLS through a cloud broker, with zero inbound ports and zero compromise to the network isolation.\nThe display cycling through the lab frames — NET latency, uptime, CPU, RAM, DISK. It earned its keep immediately: the DISK frame flagged CLAUDDEB\u0026rsquo;s root filesystem at 99% — exactly the kind of thing a glanceable display catches before it takes a service down.\nWhat\u0026rsquo;s next The one collector still stubbed out is pfSense over SNMP — real WAN throughput and firewall state, the genuinely lab-specific data. That turns \u0026ldquo;a Debian box\u0026rsquo;s stats\u0026rdquo; into \u0026ldquo;my network\u0026rsquo;s stats.\u0026rdquo;\nThe broader takeaway isn\u0026rsquo;t about LaMetric. A hard architectural constraint — these two hosts must never talk on the LAN — didn\u0026rsquo;t block the feature; it picked the design. When two things can\u0026rsquo;t connect directly, have both connect out to a point in the middle. That pattern is everywhere once you look for it.\n","permalink":"https://billalrehmani.pages.dev/posts/implementing-lametric-time-to-network/","summary":"\u003cp\u003eI picked up a LaMetric Time — an 8x37 pixel smart display — and after locking it down on an isolated guest network, the next move was to make it useful: live health from my home lab. CPU, memory, network throughput — the numbers worth a glance.\u003c/p\u003e\n\u003cp\u003eThe interesting part is that a constraint I\u0026rsquo;d deliberately built into my network dictated the whole architecture. This is the write-up: the design decision, the pipeline, and the gotchas — because the gotchas are the useful part.\u003c/p\u003e","title":"Implementing LaMetric TIME to Network"},{"content":"In Plain Terms This blog is just a personal site I created, with no intent of selling, advertising, or taking your information. It\u0026rsquo;s my own private blog that I share for career purposes.\nWhat That Means No personal data is collected, sold, or shared. No advertising or tracking for commercial purposes. The site exists to document my own technical projects and work. Contact If you have any questions about this site, you can reach me through the links on the home page.\n","permalink":"https://billalrehmani.pages.dev/privacy/","summary":"\u003ch2 id=\"in-plain-terms\"\u003eIn Plain Terms\u003c/h2\u003e\n\u003cp\u003eThis blog is just a personal site I created, with no intent of selling, advertising, or taking your information. It\u0026rsquo;s my own private blog that I share for career purposes.\u003c/p\u003e\n\u003ch2 id=\"what-that-means\"\u003eWhat That Means\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eNo personal data is collected, sold, or shared.\u003c/li\u003e\n\u003cli\u003eNo advertising or tracking for commercial purposes.\u003c/li\u003e\n\u003cli\u003eThe site exists to document my own technical projects and work.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"contact\"\u003eContact\u003c/h2\u003e\n\u003cp\u003eIf you have any questions about this site, you can reach me through the links on the \u003ca href=\"/\"\u003ehome page\u003c/a\u003e.\u003c/p\u003e","title":"Privacy Policy"},{"content":"\nThis is my second SPT mod port — the first was BiggerBang, a full trader mod. This one is smaller in scope, but I ran the entire process through Claude Fable 5 in Claude Code: extraction, code review, the rewrite, and the debugging. My role was direction and judgement calls; the model did the implementation. The most interesting part of this post is what it found.\nThe mod is FoxWeaponSoundMod v2.1.3 (by Fox), a weapon sound-replacement mod written for SPT-AKI 2.3.0 in 2022. It replaces the firing audio of ~80 weapons by re-pointing each weapon\u0026rsquo;s prefab at a custom Unity container bundle referencing replacement audio banks. SPT 4.0 moved the server from Node.js to .NET 9, so JavaScript mods stopped loading entirely. The goal: port the loader logic to 4.0.13 while leaving the original audio bundles untouched.\nLegacy vs target Legacy Target SPT version 2.3.0 4.0.13 Language JavaScript (Node) C# (.NET 9) Metadata package.json ModMetadata record (AbstractModMetadata) Entry point ModLoader.onLoad hook [Injectable(TypePriority = OnLoadOrder.PostDBModLoader + 1)] class implementing IOnLoad DB access global DatabaseServer.tables injected DatabaseService.GetItems() Bundles bundles.json + bundles/ same format; served when metadata sets IsBundleMod = true The new API is barely documented, so it was derived from three sources: the XML docs shipped with the server, decompiling SPTarkov.Server.Core.dll with ILSpy, and two working reference mods already on 4.0 — one of which uses the same prefab-path replacement technique.\nBefore rewriting anything, a script cross-checked every weapon ID in the legacy mod against the SPT 4.0.13 item database, every referenced bundle file against the shipped archive, and the bundle manifest against the files on disk. All 82 original IDs still exist in 4.0.13. Validating the data before porting the code is cheap insurance.\nThe rewrite collapsed 450 lines of copy-pasted JavaScript into a single C# dictionary (weapon ID → bundle path, generated programmatically from the original source to avoid transcription errors), applied in one loop at post-database load. The .csproj targets net9.0, references the server DLLs via an overridable SptPath property, and copies bundle assets to the output folder — bin/Release/ is the complete installable mod.\nFive bugs the original shipped with Code review surfaced defects present since 2022:\nAKMN — used the AKMSN\u0026rsquo;s item ID, which the later AKMSN entry overwrote; the AKMN never received its sound. AKS-74 — pointed at the AK-101 (5.56) bundle instead of its own (copy-paste error). TT Gold — assigned the plain TT bundle despite a dedicated gold container shipping in the mod. AK-104 — container bundle shipped but never wired up. Saiga-9 — shipped but never wired up. Three weapons gained sounds they never had in v2.1.3, four years after release.\nWhat broke along the way The archive wouldn\u0026rsquo;t extract. 7-Zip reported Unsupported Method on a RAR5 archive and silently left 0-byte files, so the extraction looked successful. Caught by checking file sizes; the official static unrar binary extracted it correctly (All OK).\nSDK mismatch. The server targets .NET 9; only the .NET 8 SDK was installed. A self-contained .NET 9 SDK in a user folder produced a build with 0 warnings, 0 errors.\nPowerShell build failures on Windows. Two argument mistakes before this worked:\ndotnet build -c Release -p:SptPath=C:\\\u0026lt;SPT folder\u0026gt; MSB1009: Project file does not exist came from malformed arguments (Release:SPTPath=... instead of -c Release -p:SptPath=...), and MSB1003: Specify a project or solution file from a space after the =, which splits the property into two arguments. The property and path must be one unbroken token.\nServer crash on the first full test. The stack trace pointed at an unrelated third-party mod reading a file with a hard-coded Windows backslash path, which fails on Linux. Isolation testing (moving other mods aside) confirmed the ported mod loaded cleanly on its own. Read the trace to the faulting module before suspecting new code.\nUnable to add bundle × 8 after deployment. On the full 42-mod install, eight container bundles failed to register. Decompilation showed the message fires on duplicate-key rejection — bundle keys are global across mods, and a content-backport mod shipping remastered versions of the same weapons owned eight overlapping keys, with load order silently deciding the winner. I chose to yield those weapons to the backport mod; since the removed entries wrote vanilla-identical paths, the deployed fix was a one-file bundles.json replacement with no rebuild.\nDisk exhaustion. Copying a multi-GB mod set aside for isolation testing filled the disk. Move-based isolation costs zero disk and achieves the same thing — the better pattern for large mod sets.\nOutcome [FoxWeaponSoundMod] v3.0.0 loaded – patched 76/76 weapon sound prefabs FoxWeaponSoundMod 3.0.0 on SPT 4.0.13, no bundle errors alongside the full 42-mod installation. Five legacy bugs fixed, one dotnet build produces the complete installable mod, and the README documents the port, fixes, and compatibility decisions.\nWhat I\u0026rsquo;m keeping from this one Verify extractions — some tools fail partially and leave empty files that look fine. When documentation is thin, decompiled sources plus a working reference mod are the fastest reliable path into a new API. Validate data (IDs, paths, manifests) with scripts before rewriting code. Bundle keys are global across mods; decide asset ownership explicitly rather than trusting load order. Compared to the BiggerBang port, the notable difference was how little I typed. The judgement calls — yielding contested weapons, accepting the isolation-test result, shipping without a rebuild — were mine. Everything between them was the model applying the same troubleshooting discipline I\u0026rsquo;d use by hand, faster.\n","permalink":"https://billalrehmani.pages.dev/posts/porting-mod-claude-fable-5/","summary":"\u003cp\u003e\u003cimg alt=\"Escape from Tarkov — the single-player mod scene this port lives in\" loading=\"lazy\" src=\"/images/posts/spt-aki-gameplay.jpg\"\u003e\u003c/p\u003e\n\u003cp\u003eThis is my second SPT mod port — the first was \u003ca href=\"/posts/spt-aki-typescript-to-csharp-port/\"\u003eBiggerBang\u003c/a\u003e, a full trader mod. This one is smaller in scope, but I ran the entire process through Claude Fable 5 in Claude Code: extraction, code review, the rewrite, and the debugging. My role was direction and judgement calls; the model did the implementation. The most interesting part of this post is what it found.\u003c/p\u003e","title":"Porting a Mod Through Claude Fable 5"},{"content":"\nI wanted a small desktop tool that captures a snapshot of a machine — hardware, resource usage, network state — and writes it to a styled HTML report viewable in any browser. Useful for quick system audits and for keeping a record of a machine\u0026rsquo;s specs over time. I built it in one Claude Code session (Opus 4.8) on my Debian 13 laptop, then packaged it for Windows as well.\nThe prompt that started it The entire specification was one sentence:\ncan you create me a simple local program for myself with fancy gui with the whole purpose of gathering system information and it generating to a file location of whatever chose in the input, give me any prompt of info if you require\nWhat Claude did first mattered more than the code: it inspected the machine before writing anything. It found Python 3.13.5 with psutil installed, no GUI toolkit, and a PEP 668 externally-managed environment — then asked structured questions about GUI approach and output format instead of guessing. I chose a native desktop window and HTML output.\nFirst working version Choice Decision GUI framework customtkinter — native dark-mode desktop window System info source psutil Output format Styled, self-contained HTML report Packaging PyInstaller — single-file executable Design Background thread + live progress bar; each probe isolated so one failure won\u0026rsquo;t crash the app After installing the missing Tk dependency (pkexec apt install -y python3-tk), the first version came up: a dark window with category checkboxes, an output folder picker, and a generate button.\nIt collects eight categories:\nCategory Details System / OS Hostname, user, OS, kernel, architecture, boot time, uptime CPU Physical/logical cores, frequency, per-core usage, load average Memory RAM and swap — total / used / available / percent Disk Every mounted partition (size, used, free) + total I/O Network Interfaces, IP/MAC addresses, bytes sent/received, active connections Logged-in Users Active user sessions Sensors / Battery Battery %, power state, temperatures (if available) Top Processes 15 heaviest processes by RAM Upgrade rounds Tarkov re-skin. A darkened Escape from Tarkov BEAR emblem became the window background (processed with Pillow — brightness, red duotone, vignette), and the interface was recoloured red/black to match this blog. JSON → HTML. The plain JSON export became the styled, self-contained report at the top of this post — emblem embedded as base64, one table per section, optional auto-open in the browser. Optimisation and security hardening: Fixed per-core CPU usage returning zeros — now one accurate 1-second sample, with the total as the mean. Path-traversal guard: output filenames are stripped to a basename so they can\u0026rsquo;t escape the chosen folder. HTML-injection safe: every dynamic value is escaped, verified with an injected \u0026lt;script\u0026gt; payload. Browser auto-open uses Path.as_uri(), which handles spaces correctly. The embedded emblem is computed once and cached; asset paths resolve correctly when running as a frozen executable. Packaging — Linux executable and a Windows build kit PyInstaller bundled the app into a standalone Linux executable (~18 MB) — no Python needed to run it.\nA Windows .exe can\u0026rsquo;t be cross-built from Linux, so the project ships a Windows_Build_Kit/ instead: build.bat, the source, requirements.txt, theme assets, and a README. Copy to a Windows PC, run build.bat, and it produces the executable locally.\nThat\u0026rsquo;s how it went on my Windows PC — one run of build.bat, and the resulting exe (~19 MB) launches the same red/black GUI:\nVerification Data-gathering tested headless — 16 logical cores, 15 GB RAM, 8 disks read correctly; all 8 collector categories passed. HTML report confirmed valid and self-contained. \u0026lt;script\u0026gt; injection test confirmed escaping works. Linux executable smoke-tested: clean launch, assets and theme loaded, no errors. The Windows build command was validated on the Linux machine before shipping the kit. How I use it Launch the app (desktop launcher or the executable directly). Tick the categories to collect. Choose the output folder and optionally edit the filename (defaults to sysinfo_\u0026lt;hostname\u0026gt;_\u0026lt;timestamp\u0026gt;.html). Click GENERATE REPORT — the report saves and opens in the browser. Small tool, but the workflow is the takeaway: one plain-English prompt, a model that inspects the environment and asks before building, and an afternoon later there\u0026rsquo;s a hardened, packaged program running on two operating systems.\n","permalink":"https://billalrehmani.pages.dev/posts/systeminfo-grabber-claude-ai/","summary":"\u003cp\u003e\u003cimg alt=\"The finished HTML report — dark red/black theme, one card per category\" loading=\"lazy\" src=\"/images/posts/sysinfo-html-report.png\"\u003e\u003c/p\u003e\n\u003cp\u003eI wanted a small desktop tool that captures a snapshot of a machine — hardware, resource usage, network state — and writes it to a styled HTML report viewable in any browser. Useful for quick system audits and for keeping a record of a machine\u0026rsquo;s specs over time. I built it in one Claude Code session (Opus 4.8) on my Debian 13 laptop, then packaged it for Windows as well.\u003c/p\u003e","title":"Created a SystemInfo Grabber Program with Claude AI"},{"content":"\nI run my Claude Code work inside a Debian 13 VM (CLAUDDEB) on VMware Workstation Pro 17.6.4, with a pfSense 2.8.1 VM in front of it as a virtual router and firewall. pfSense exists in this setup for containment: if something on the Debian VM misbehaves — a prompt injection, a compromised dependency — it must not be able to reach my PC, my router\u0026rsquo;s admin page, or anything else on the home network.\nOne day the lab had no internet at all. This post covers both halves of that session: diagnosing an outage that turned out to be the Windows hypervisor\u0026rsquo;s fault, and rebuilding pfSense\u0026rsquo;s WAN and firewall rules so the design no longer depends on the layer that failed.\nThe layout Before — Debian on a host-only network (pfSense\u0026rsquo;s LAN), pfSense\u0026rsquo;s WAN on VMware NAT:\nDebian VM (CLAUDDEB) pfSense (PfSense secondary) Host / Home ens33 ── host-only ──► LAN (em1) ─────────────────── WAN (em0) ── VMware NAT ──► Host ──► Arcadyan ──► Internet 10.10.0.102/24 10.10.0.1/24 192.168.62.130/24 (vmnet8) After — Debian unchanged, pfSense\u0026rsquo;s WAN bridged straight to the home router:\nDebian VM (CLAUDDEB) pfSense (PfSense secondary) Home ens33 ── host-only ──► LAN (em1) ─────────────────── WAN (em0) ── bridged ──► Arcadyan ──► Internet 10.10.0.102/24 10.10.0.1/24 192.168.1.189/24 No VMware NAT service in the path, and egress rules on the LAN interface block the Debian VM from every private range.\nPart 1 — the outage After resuming the VMs from suspend, the Debian VM had no internet. Both VMs pinged each other fine and reported \u0026ldquo;connected\u0026rdquo;, but nothing external worked. I followed the packet path outward, splitting the problem at each layer:\nGateway status (System \u0026gt; Routing \u0026gt; Gateways): WAN_DHCP showed its gateway (192.168.62.2) online — but \u0026ldquo;online\u0026rdquo; only means gateway monitoring can ping the next hop, not that traffic flows.\nA firewall notice showed the ruleset had failed to load:\nThere were error(s) loading the rules: cannot define table bogonsv6: Cannot allocate memory - table \u0026lt;bogonsv6\u0026gt; persist file \u0026#34;/etc/bogonsv6\u0026#34; On resume, pf couldn\u0026rsquo;t allocate a block large enough for the bogonsv6 table, aborting the whole ruleset. Fixed by disabling Block bogon networks on WAN (pointless on a private NAT address anyway) and adding RAM headroom. A real fix — but not the root cause.\nRouting table: default route 0.0.0.0 → 192.168.62.2 via em0 present and correct.\nDNS from pfSense — the key clue: a lookup against the VMware NAT DNS proxy (192.168.62.2) answered in 3 ms, while pfSense\u0026rsquo;s own Unbound resolver timed out.\nFrom the Debian VM: curl failed with Could not resolve host; ping 8.8.8.8 lost 100%. Debian\u0026rsquo;s own config was clean. Narrowing DNS:\ndig pfSense.peas.arpa @10.10.0.1 # answers instantly (local record) dig +tcp google.com @10.10.0.1 # answers over TCP dig google.com @10.10.0.1 # SERVFAIL over UDP nc -zvu 10.10.0.1 53 # port 53 open Unbound was alive, TCP worked, but external recursion failed — even in forwarding mode with DNSSEC off.\nThe decisive test A direct host google.com 192.168.62.2 from pfSense now timed out — the same NAT DNS proxy that had answered in 3 ms earlier. A raw TCP test from Debian (curl -v https://1.1.1.1) timed out too. All traffic crossing the VMware NAT device was failing — TCP, UDP and ICMP — while everything local kept working.\nRoot cause: the Windows hypervisor That pattern, surviving multiple full host reboots, pointed at the host:\nGet-CimInstance Win32_ComputerSystem | Select-Object HypervisorPresent # True Get-WindowsOptionalFeature -Online | Where-Object {$_.State -eq \u0026#34;Enabled\u0026#34; -and $_.FeatureName -match \u0026#34;Hyper|Hypervisor|VirtualMachinePlatform|WindowsHypervisorPlatform|WSL\u0026#34;} # VirtualMachinePlatform Enabled # HypervisorPlatform Enabled The Windows hypervisor (VBS / Virtualization-Based Security) was active. When it runs, it takes ownership of the network stack in a way that breaks VMware Workstation\u0026rsquo;s NAT forwarding — while VMware\u0026rsquo;s NAT and DHCP services still show \u0026ldquo;Running\u0026rdquo; and vmnet8 shows \u0026ldquo;Up\u0026rdquo;, which is why every check looked healthy. It survived reboots because the feature was enabled, almost certainly flipped on silently by a Windows update. Host proxy settings, hosts file, WFP filters, services and Run keys were all checked clean, ruling out malware.\nThe fix bcdedit /set hypervisorlaunchtype off alone wasn\u0026rsquo;t enough — VBS re-launched the hypervisor. Disabling the underlying features worked:\nreg add \u0026#34;HKLM\\SYSTEM\\CurrentControlSet\\Control\\DeviceGuard\u0026#34; /v EnableVirtualizationBasedSecurity /t REG_DWORD /d 0 /f reg add \u0026#34;HKLM\\SYSTEM\\CurrentControlSet\\Control\\DeviceGuard\\Scenarios\\HypervisorEnforcedCodeIntegrity\u0026#34; /v Enabled /t REG_DWORD /d 0 /f Disable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -NoRestart Disable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform -NoRestart bcdedit /set hypervisorlaunchtype off Plus Memory Integrity off in Windows Security → Core isolation. After a reboot, HypervisorPresent returned False and connectivity came straight back: 0% loss to 8.8.8.8, and curl -I https://google.com returned HTTP/2 301.\nOne self-inflicted detour worth recording: mid-diagnosis, the Debian VM\u0026rsquo;s adapter got switched from host-only to NAT while running (the VMware log confirmed it: ConfigDB: Setting ethernet0.connectionType = \u0026quot;nat\u0026quot;), putting it on the wrong subnet entirely. Don\u0026rsquo;t change a running VM\u0026rsquo;s adapter mid-diagnosis.\nPart 2 — hardening: bridged WAN + egress rules Even fixed, the NAT-based WAN had two problems for a containment lab: it depended on the fragile VMware NAT layer, and re-enabling Windows Memory Integrity would break it again. A bridged WAN removes both.\nBridging doesn\u0026rsquo;t weaken isolation. The security property is egress control — the Debian VM reaches the internet but cannot initiate connections into the home network — and that\u0026rsquo;s enforced by firewall rules on pfSense\u0026rsquo;s LAN interface, not by the WAN type. The responsibility moves onto the egress rules, where it belongs.\nThe changes Switched pfSense WAN to bridged. Set VMware\u0026rsquo;s bridged network to the physical adapter explicitly, changed ethernet0.connectionType from \u0026quot;nat\u0026quot; to \u0026quot;bridged\u0026quot; in the .vmx with the VM off, and let WAN pull a DHCP lease from the home router (192.168.1.189/24). LAN unchanged at 10.10.0.1/24. Home subnet is therefore 192.168.1.0/24, with the router as the gateway.\nCreated an RFC1918 alias covering all private address space (Firewall \u0026gt; Aliases \u0026gt; IP):\nName Type Networks RFC1918 Network(s) 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 LAN firewall rules (first match wins, top to bottom):\n# Action Source Destination Purpose 1 (Anti-Lockout — pfSense managed) * LAN Address :443,80,22 Guarantees web UI access 2 Pass LAN subnets LAN address DNS / gateway access to pfSense 3 Block LAN subnets RFC1918 (alias) Blocks all private/home ranges 4 Pass LAN subnets any Internet access The ordering is the point: DNS-to-pfSense allowed first, all private-range traffic dropped, then the public internet allowed. An agent on the Debian VM can reach out but cannot reach back in.\nVerification from the Debian VM curl -I https://google.com # internet -\u0026gt; HTTP/2 301 (works) dig google.com @10.10.0.1 # DNS -\u0026gt; resolves (works) ping -c2 \u0026lt;router\u0026gt; # Arcadyan -\u0026gt; times out (blocked) ping -c2 192.168.1.189 # pfSense WAN -\u0026gt; times out (blocked) Internet and DNS work; everything on the home subnet is unreachable. Containment proven. The dashboard screenshot at the top shows the final state — WAN bridged, LAN on 10.10.0.1, both interfaces up at 1000baseT full-duplex.\nOutcome and habits Root cause was the Windows hypervisor breaking VMware NAT — not the lab configuration. The bridged WAN removes the VMware NAT dependency entirely, so Memory Integrity / VBS can be re-enabled on the host without breaking the lab. After Windows updates, re-check HypervisorPresent — with the bridged WAN it no longer matters, but it\u0026rsquo;s worth knowing. Shut VMs down rather than suspending them; suspend/resume of a router VM kept triggering clock skew and NAT problems. Start pfSense first so the DHCP lease is always ready. Both .vmx files had tools.syncTime = \u0026quot;FALSE\u0026quot;, which lets guest clocks drift after suspend and can break DNSSEC/NTP. Threat model caveat This setup contains an agent\u0026rsquo;s network access — a prompt-injected or misbehaving process on the Debian VM can\u0026rsquo;t reach the home network. It is not a guarantee against a determined VM-escape exploit; VMware has had such vulnerabilities historically. For network-level containment the design is solid; defending against escape as well would mean a separate physical machine or a disposable cloud instance.\n","permalink":"https://billalrehmani.pages.dev/posts/pfsense-lab-recovery-and-hardening/","summary":"\u003cp\u003e\u003cimg alt=\"pfSense dashboard after the rebuild — WAN bridged on 192.168.1.189, LAN on 10.10.0.1, pfSense 2.8.1\" loading=\"lazy\" src=\"/images/posts/pfsense-dashboard.png\"\u003e\u003c/p\u003e\n\u003cp\u003eI run my Claude Code work inside a Debian 13 VM (CLAUDDEB) on VMware Workstation Pro 17.6.4, with a pfSense 2.8.1 VM in front of it as a virtual router and firewall. pfSense exists in this setup for containment: if something on the Debian VM misbehaves — a prompt injection, a compromised dependency — it must not be able to reach my PC, my router\u0026rsquo;s admin page, or anything else on the home network.\u003c/p\u003e","title":"Debugging a Dead VMware NAT and Hardening My pfSense Containment Lab"},{"content":"Who I Am I\u0026rsquo;m Billal, an IT student based in Australia with nearly six years in the field. I\u0026rsquo;m currently completing a Diploma in Cyber Security, with graduation set for late 2026.\nMy main interests span networking, cybersecurity, virtualisation, Linux, and gaming — and more often than not, several of those overlap in the same project.\nHow I Got Here One of my teachers introduced me to Claude early in my studies, and it genuinely changed how I think about AI as a tool. I went from treating it as a novelty to using it as a proper part of my workflow — for studying, building, troubleshooting, and writing.\nWhat I Do Networking \u0026amp; Security — hands-on practice with Linux CLI, Windows PowerShell, pfSense, and virtual lab environments Virtualisation — running Debian and pfSense VMs, using Claude Code and Claude Desktop within isolated environments Game Modding — as a heavy gamer, I mod and extend games in ways I couldn\u0026rsquo;t do without AI assistance Side Projects — small builds and experiments I do in my own time, mostly to learn by doing Skills \u0026amp; Tools Grouped by what I actually build and troubleshoot in the projects on this site:\nNetworking — pfSense firewalls, egress/RFC1918 containment rules, guest-network segmentation, WPA3, DNS, SNMP, Tailscale / WireGuard mesh VPN Security — network isolation and containment design, host and service hardening, least-privilege, threat modelling, defence-in-depth Linux \u0026amp; systems — Debian administration, systemd services, Bash, service hardening, self-hosting Virtualisation — VMware Workstation, VM networking, isolated lab environments Programming — Python, C# / .NET, TypeScript — small tools, and porting code between them Observability — Prometheus, Grafana, node_exporter / snmp_exporter, MQTT pipelines AI-assisted workflow — Claude Code, custom MCP servers, agentic build and troubleshooting Web \u0026amp; tooling — Hugo, Git and GitHub Actions, this self-hosted blog What This Blog Is This blog documents what I actually do with Claude — not polished tutorials, just real projects, small wins, and things I figured out along the way. If it ended up working, it probably ended up here.\nWant to get in touch? The GitHub and LinkedIn links are on the home page.\n","permalink":"https://billalrehmani.pages.dev/about/","summary":"\u003ch2 id=\"who-i-am\"\u003eWho I Am\u003c/h2\u003e\n\u003cp\u003eI\u0026rsquo;m Billal, an IT student based in Australia with nearly six years in the field. I\u0026rsquo;m currently completing a Diploma in Cyber Security, with graduation set for late 2026.\u003c/p\u003e\n\u003cp\u003eMy main interests span networking, cybersecurity, virtualisation, Linux, and gaming — and more often than not, several of those overlap in the same project.\u003c/p\u003e\n\u003ch2 id=\"how-i-got-here\"\u003eHow I Got Here\u003c/h2\u003e\n\u003cp\u003eOne of my teachers introduced me to Claude early in my studies, and it genuinely changed how I think about AI as a tool. I went from treating it as a novelty to using it as a proper part of my workflow — for studying, building, troubleshooting, and writing.\u003c/p\u003e","title":"About"},{"content":"Studies Currently on semester break After the break: setting up and configuring a network based on a given scenario Also have ethics and policies projects coming up next semester Projects \u0026amp; Labs Working through lab documents to upskill in Linux command line and Windows PowerShell Planning to buy a Raspberry Pi to run as a filter proxy and ad blocker on my home network Gaming Single Player Tarkov (SPT) — modding and playing heavily; SPT is a standalone mod of the official EFT Minecraft — heavily modded playthroughs Warframe LEGO Batman Legacy Batman: Arkham Knight — modding and playing Plus many more Learning Deepening my Linux skills and pfSense configuration Upskilling in Windows PowerShell Strengthening core networking concepts Following AI trends and exploring how AI integrates into IT workflows ","permalink":"https://billalrehmani.pages.dev/now/","summary":"\u003ch2 id=\"studies\"\u003eStudies\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eCurrently on semester break\u003c/li\u003e\n\u003cli\u003eAfter the break: setting up and configuring a network based on a given scenario\u003c/li\u003e\n\u003cli\u003eAlso have ethics and policies projects coming up next semester\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"projects--labs\"\u003eProjects \u0026amp; Labs\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eWorking through lab documents to upskill in Linux command line and Windows PowerShell\u003c/li\u003e\n\u003cli\u003ePlanning to buy a Raspberry Pi to run as a filter proxy and ad blocker on my home network\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"gaming\"\u003eGaming\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSingle Player Tarkov (SPT)\u003c/strong\u003e — modding and playing heavily; SPT is a standalone mod of the official EFT\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMinecraft\u003c/strong\u003e — heavily modded playthroughs\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eWarframe\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eLEGO Batman Legacy\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBatman: Arkham Knight\u003c/strong\u003e — modding and playing\u003c/li\u003e\n\u003cli\u003ePlus many more\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"learning\"\u003eLearning\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eDeepening my Linux skills and pfSense configuration\u003c/li\u003e\n\u003cli\u003eUpskilling in Windows PowerShell\u003c/li\u003e\n\u003cli\u003eStrengthening core networking concepts\u003c/li\u003e\n\u003cli\u003eFollowing AI trends and exploring how AI integrates into IT workflows\u003c/li\u003e\n\u003c/ul\u003e","title":"Now"},{"content":"A log of small projects completed using Claude Code CLI on a personal Debian laptop. Each session was self-contained — Claude handled the implementation while I directed the goal.\nProject 1 — System Environment Setup Detail Info Date 23 June 2026 Status Completed Ran the built-in /doctor command to verify the Claude Code installation was healthy, paths were correct, and the environment was ready to use on the laptop.\nProject 2 — OneDrive Setup \u0026amp; Office Tooling Detail Info Date 24 June 2026 Status Completed Set up cloud storage and document tooling on the Debian laptop:\nInstalled and configured OneDrive via Snap, authenticated it, and enabled the background sync daemon for persistent file syncing Installed LibreOffice Writer as a document editor Four guided lab documents were generated, formatted, and saved to OneDrive:\nDocument Topic Linux_Command_Line_Lab.docx Linux command reference with usage examples Windows_PowerShell_Lab.docx PowerShell syntax and cross-platform shell skills Docker_Intro_Lab.docx Introduction to Docker containers Apache_Lab.docx Apache web server setup and configuration Project 3 — Obsidian Vault Optimisation \u0026amp; Claude API Detail Info Date 24 June 2026 Status Completed Two things were done in the same session: restructuring an existing Obsidian notes vault, then wiring Claude into it.\nVault clean-up:\nStandardised formatting, headings, and spacing across all notes Added a Table of Contents to each note Created three supporting files: a formatting guide, a blank note template, and an optimisation log Updated the vault\u0026rsquo;s Canvas diagram to reflect the current note structure Claude API integration:\nConnected the Claude API to Obsidian so Claude can read notes, suggest edits, and generate content from within the vault This makes the vault AI-assisted going forward — notes can be summarised, reformatted, or extended using Claude as a backend Project 4 — Lab Document Migration to Obsidian Detail Info Date 26 June 2026 Status Completed Retrieved the lab .docx files from OneDrive and converted them into properly formatted Markdown notes inside the Obsidian vault:\nObsidian Note Vault Location Linux Command Line Lab.md Linux Fundamentals/ Windows PowerShell Lab.md Cybersecurity Notes/Cluster 2 Notes/ Each imported note was brought up to vault standards — YAML frontmatter, a Table of Contents, correct heading hierarchy, fenced code blocks, and cross-links between the two notes.\nSummary # Project Date Result 1 Environment Setup 23 Jun 2026 Claude Code verified and ready 2 OneDrive + Office Tooling 24 Jun 2026 Cloud sync active, 4 lab docs created 3 Obsidian Vault + Claude API 24 Jun 2026 Vault restructured, Claude API integrated 4 Lab Doc Migration 26 Jun 2026 Docs converted to Markdown in vault All projects were completed on a Debian Linux laptop using Claude Code CLI.\n","permalink":"https://billalrehmani.pages.dev/laptop-claude-proj/","summary":"\u003cp\u003eA log of small projects completed using Claude Code CLI on a personal Debian laptop. Each session was self-contained — Claude handled the implementation while I directed the goal.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"project-1--system-environment-setup\"\u003eProject 1 — System Environment Setup\u003c/h2\u003e\n\u003cp\u003e\u003cimg alt=\"Claude AI — the AI assistant powering these projects\" loading=\"lazy\" src=\"/images/posts/claude-ai-logo.svg\"\u003e\u003c/p\u003e\n\u003ctable\u003e\n\t\u003cthead\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003cth\u003eDetail\u003c/th\u003e\n\t\t\t\t\t\u003cth\u003eInfo\u003c/th\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/thead\u003e\n\t\u003ctbody\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eDate\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003e23 June 2026\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\t\t\u003ctr\u003e\n\t\t\t\t\t\u003ctd\u003eStatus\u003c/td\u003e\n\t\t\t\t\t\u003ctd\u003eCompleted\u003c/td\u003e\n\t\t\t\u003c/tr\u003e\n\t\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp\u003eRan the built-in \u003ccode\u003e/doctor\u003c/code\u003e command to verify the Claude Code installation was healthy, paths were correct, and the environment was ready to use on the laptop.\u003c/p\u003e","title":"Laptop Claude Proj"},{"content":"\nSPT-AKI (Single Player Tarkov) 4.0 rewrote the entire server from JavaScript/TypeScript to C#/.NET. Every SPT 3.x mod — loaded as .ts/.js files with a package.json — had to be rebuilt as a compiled .dll against the new C# API.\nThe mod I ported is BiggerBang, written for SPT 3.9 by Thunderbags, whose author had gone inactive. It adds a full custom trader (Boris Bangski) with an extensive inventory — ammo, weapons, magazines, grenades, injectors, containers, armour, equipment sets — and 13 quests. I ported it to C# for SPT 4.0.x, verified it against 4.0.13, fixed five bugs found along the way, and released it to the community.\nWhat the port involved The 3.x→4.x migration is a complete API break:\nSPT 3.x (TypeScript) SPT 4.x (C#) mod.ts + package.json BiggerBangMod.cs + ModMetadata : AbstractModMetadata IPreSptLoadMod / IPostDBLoadMod IOnLoad with [Injectable(PostDBModLoader + 1)] container.resolve(\u0026quot;ServiceName\u0026quot;) Constructor dependency injection Readable item IDs (strings) ToId hash mechanism — IDs derived by hashing The content carried over 1:1 — trader, inventory, all 13 quests, prices, loyalty levels. The work was structural translation, not redesign.\nI worked with Claude Code, giving it filesystem access to the live server and my dev workspace. It read the source, diagnosed log errors, and applied edits; I directed the port and verified the output. That division of labour is the important part of how this worked.\nFive hardening fixes The port itself wasn\u0026rsquo;t the hard part — the valuable work was what surfaced once it was running:\nDecoupled weapons from the ammo toggle. A single AmmoEnabled flag controlled both; added a separate WeaponsEnabled config flag. Fixed registration order. Grenade-launcher magazines reference the launcher, so it must exist first. Reordered weapon/magazine loading and linked msglAuto correctly. Added a database-existence guard in CreateItemOffer. Failed items were still being added to trader stock and the flea market, creating dangling offers — also the root cause of a stray insurance error in the original. Extended ConvertIds to rewrite _tpl fields. The quest-reward ID rewriter missed them, so some rewards never resolved — this also fixed the Quest05a skip. Flipped UnlockAllItemsLL1 to false. The original bypassed loyalty progression entirely — the wrong default for a community release. A deploy bug worth recording My deploy script backed up the old build into user/mods, which SPT scans for DLLs on startup — so it loaded both copies and threw a duplicate-assembly error.\n# Wrong — SPT scans this folder for DLLs C:\\SPT-4.0\\user\\mods\\_backup\\ # Right C:\\SPT-4.0\\_mod_backups\\ Back up outside the scanned directory.\nA second mod fixed along the way The SOCOM trader mod shipped six item template IDs that don\u0026rsquo;t exist in SPT 4.0.13\u0026rsquo;s database (they belong to a newer EFT patch), causing a flea-market cache error on every startup. A cleanup script removed those six entries and their barter/loyalty references — nothing else touched.\nRelease MIT licensed, matching the original, with full attribution to Thunderbags and contributors (Tuhjay, GhostFenixx, Spartacus). Released as a community port while the original author is inactive, with a note that I\u0026rsquo;ll hand it back if they return. A PORT_SUMMARY.md documents the rationale, the full API mapping, and each fix, so future maintainers have the reasoning.\nTakeaway I\u0026rsquo;m not claiming to be a C# developer. The takeaway is that I understood the SPT mod system end to end well enough to direct an AI through a full language port, verify the result against a live server, and catch five real bugs in the process. The mod runs clean on 4.0.13.\n","permalink":"https://billalrehmani.pages.dev/posts/spt-aki-typescript-to-csharp-port/","summary":"\u003cp\u003e\u003cimg alt=\"Escape from Tarkov\" loading=\"lazy\" src=\"/images/posts/escape-from-tarkov.jpg\"\u003e\u003c/p\u003e\n\u003cp\u003eSPT-AKI (Single Player Tarkov) 4.0 rewrote the entire server from JavaScript/TypeScript to C#/.NET. Every SPT 3.x mod — loaded as \u003ccode\u003e.ts\u003c/code\u003e/\u003ccode\u003e.js\u003c/code\u003e files with a \u003ccode\u003epackage.json\u003c/code\u003e — had to be rebuilt as a compiled \u003ccode\u003e.dll\u003c/code\u003e against the new C# API.\u003c/p\u003e\n\u003cp\u003eThe mod I ported is BiggerBang, written for SPT 3.9 by Thunderbags, whose author had gone inactive. It adds a full custom trader (Boris Bangski) with an extensive inventory — ammo, weapons, magazines, grenades, injectors, containers, armour, equipment sets — and 13 quests. I ported it to C# for SPT 4.0.x, verified it against 4.0.13, fixed five bugs found along the way, and released it to the community.\u003c/p\u003e","title":"Porting a TypeScript Game Mod to C# and Hardening It for Community Release"},{"content":"\nThis is a small home network — one router, a handful of devices. The point wasn\u0026rsquo;t complexity; it was applying the same discipline you would to a small office or lab environment. Treated that way, it doubles as practical study for Network+ and Security+.\nThe router is an Arcadyan HWG2025 — the NBN-issued unit, Wi-Fi 7 with MLO, around 500 Mb down. An ISP router doesn\u0026rsquo;t give you much room to move, but it gives you enough to do this properly.\nBaseline hardening Before any segmentation:\nChanged the default admin credentials. ISP defaults are usually admin/admin or printed on a sticker. WPA3 with AES everywhere. WPA2 fallback only for devices that genuinely can\u0026rsquo;t do WPA3; TKIP off. Disabled WPS — a known weak point with no upside. Disabled remote management — the admin interface has no reason to be reachable from outside. Updated firmware and rebooted before further changes. Set DHCP reservations for important devices so their addresses are predictable. None of this is advanced. It\u0026rsquo;s the baseline that everything else builds on.\nNetwork segmentation Two zones:\nZone Subnet Notes Main / trusted 192.168.1.0/24 My machines Guest 192.168.2.0/24 Visitors, untrusted devices Guest has client isolation enabled — devices on it can\u0026rsquo;t reach the main network or each other. Anything I don\u0026rsquo;t fully trust (visitors, and eventually IoT devices) never shares a broadcast domain with my machines.\nAn IoT segment as a third zone is the longer-term plan; two zones is the right starting point.\nThe DNS gotcha The HWG2025 has ISP-locked DNS fields at the router level — you can\u0026rsquo;t point the upstream resolvers at Cloudflare (1.1.1.1) or Quad9 (9.9.9.9) from the admin interface.\nTwo workarounds:\nPer-device OS-level DNS — set 1.1.1.1 and 9.9.9.9 on each machine. Works, but it\u0026rsquo;s manual and doesn\u0026rsquo;t cover devices you can\u0026rsquo;t configure. Pi-hole as local DNS — point all DHCP clients at a Pi-hole, which upstreams to Cloudflare/Quad9. Network-wide resolver control, plus ad and tracker blocking. Pi-hole is the long-term answer and will get its own post. For now, per-device DNS on the machines I control.\nBand steering and SSID choices With Wi-Fi 7 and MLO, 2.4 GHz and 5 GHz don\u0026rsquo;t need to be split into separate SSIDs. I left them merged with Smart Connect on. The one exception: stubborn IoT gear that only speaks 2.4 GHz — split temporarily, connect the device, merge back.\nI didn\u0026rsquo;t hide the SSID. Hidden networks are trivially detectable with any wireless scanner, and devices searching for a hidden network broadcast its name wherever they go. Real security here is WPA3, guest isolation, strong admin credentials, and WPS off — not hiding the name.\nWhere this goes next The discipline matters more than the scale. Separating trusted from untrusted traffic, disabling what shouldn\u0026rsquo;t be on, and using strong authentication are the same decisions at home as in production — only the size changes.\nNext: Pi-hole for proper DNS control, then IoT segmentation as a third zone.\n","permalink":"https://billalrehmani.pages.dev/posts/home-network-hardening-hwg2025/","summary":"\u003cp\u003e\u003cimg alt=\"Arcadyan HWG2025 (iiNet Wi-Fi Max) — the router this post is about\" loading=\"lazy\" src=\"/images/posts/hwg2025-router.jpg\"\u003e\u003c/p\u003e\n\u003cp\u003eThis is a small home network — one router, a handful of devices. The point wasn\u0026rsquo;t complexity; it was applying the same discipline you would to a small office or lab environment. Treated that way, it doubles as practical study for Network+ and Security+.\u003c/p\u003e\n\u003cp\u003eThe router is an Arcadyan HWG2025 — the NBN-issued unit, Wi-Fi 7 with MLO, around 500 Mb down. An ISP router doesn\u0026rsquo;t give you much room to move, but it gives you enough to do this properly.\u003c/p\u003e","title":"Hardening and Segmenting My Home Network on an Arcadyan HWG2025"},{"content":"The blog you\u0026rsquo;re reading was published by the pipeline this post describes.\nWhy Hugo, and why self-hosted I needed somewhere to document real technical work — networking, security, mod projects — that I could point to from LinkedIn. Hosted platforms were out: no content ownership, paywall friction, someone else\u0026rsquo;s branding.\nHugo won on attack surface. It\u0026rsquo;s a single Go binary — no Node, no node_modules, no npm dependency tree to patch and audit. For a security portfolio, the blog itself should be as defensible as the work it documents. It builds to plain static files, so serving is trivial; the trade-off is owning uptime and patching, which for this use case is a feature.\nThe stack:\nHugo + PaperMod theme Posts as Markdown in content/posts/ hugo --minify builds to public/ Deploy: rsync to a Debian box behind nginx with TLS The publishing pipeline The target workflow: drop raw notes in the repo, run one command, get a finished post committed and deployed. Claude Code\u0026rsquo;s slash commands (custom prompts in .claude/commands/) made that practical. Three files do the work:\nCLAUDE.md — project instructions loaded every session: stack, post conventions, front matter spec, and a pointer to the context file. blog-author-context.md — my background and writing voice, kept separate so it stays portable. .claude/commands/newpost.md — the /newpost command: reads a notes file, rewrites it in my voice, creates content/posts/\u0026lt;slug\u0026gt;.md with correct front matter, commits, and deploys. # drop raw notes in the repo notes/some-post.md # run the command /newpost notes/some-post.md Claude drafts the post and waits for my approval before committing. I kept that review gate deliberately — this blog is public and tied to my name, and auto-publishing unreviewed text isn\u0026rsquo;t a risk worth taking until the output has earned trust.\nThe gap worth knowing: two separate Claude memory systems This wasn\u0026rsquo;t clearly documented anywhere I looked. The Claude chat app and Claude Code run completely separate memory systems that never sync:\nThe chat app builds memory automatically from past conversations — after months of use, it knows your background and projects. Claude Code starts blank. It has CLAUDE.md (manual, version-controlled, per-repo) and its own auto memory in ~/.claude/projects/ — and it never pulls from the chat app. So on first run, Claude Code had no idea who I was, despite months of chat history. The fix: export the relevant background into blog-author-context.md and have CLAUDE.md load it every session. A manual bridge, but version-controlled and reliable.\nThe pattern: anything every session should know must live in a file the repo loads explicitly. Don\u0026rsquo;t assume chat-app context carries over.\nWhat the first runs looked like The first draft had the right structure but the wrong voice — too clean, too passive, not enough about what broke. I made the author context explicit: include failures and fixes, keep real commands and error output, short paragraphs, cut anything that doesn\u0026rsquo;t earn its place. By the third run, drafts needed only minor edits.\nThe rsync deploy also has \u0026lt;user\u0026gt; and \u0026lt;server\u0026gt; placeholders that must be set before deployment works; the pipeline stops if they aren\u0026rsquo;t.\nWhat\u0026rsquo;s next With the pipeline working, the backlog of write-ups: home network hardening on the Arcadyan HWG2025, the SPT-AKI mod port, and further lab builds. This post was the foundation; the rest is filling it in.\n","permalink":"https://billalrehmani.pages.dev/posts/self-hosted-hugo-claude-code-pipeline/","summary":"\u003cp\u003eThe blog you\u0026rsquo;re reading was published by the pipeline this post describes.\u003c/p\u003e\n\u003cp\u003e\u003cimg alt=\"Hugo — the static site generator this blog runs on\" loading=\"lazy\" src=\"/images/posts/hugo-logo.svg\"\u003e\u003c/p\u003e\n\u003ch2 id=\"why-hugo-and-why-self-hosted\"\u003eWhy Hugo, and why self-hosted\u003c/h2\u003e\n\u003cp\u003eI needed somewhere to document real technical work — networking, security, mod projects — that I could point to from LinkedIn. Hosted platforms were out: no content ownership, paywall friction, someone else\u0026rsquo;s branding.\u003c/p\u003e\n\u003cp\u003eHugo won on attack surface. It\u0026rsquo;s a single Go binary — no Node, no \u003ccode\u003enode_modules\u003c/code\u003e, no npm dependency tree to patch and audit. For a security portfolio, the blog itself should be as defensible as the work it documents. It builds to plain static files, so serving is trivial; the trade-off is owning uptime and patching, which for this use case is a feature.\u003c/p\u003e","title":"Self-Hosting a Hugo Blog with a Claude Code Publishing Pipeline"},{"content":"Real work on my own gear, written up honestly — including what broke. Each project links to its full post.\npfSense containment lab A pfSense VM firewalling the Debian VM where my Claude Code agents run — egress rules allow internet-out and block every private range. Includes diagnosing a full outage caused by a Windows update silently re-enabling the hypervisor.\nRead: Debugging a Dead VMware NAT and Hardening My pfSense Containment Lab →\nHome network hardening Baseline hardening and guest segmentation on an ISP-issued Arcadyan HWG2025: WPA3, WPS off, remote management off, two isolated zones, and the ISP-locked DNS workaround.\nRead: Hardening and Segmenting My Home Network on an Arcadyan HWG2025 →\nVirtual VLAN segmentation on pfSense Three firewall-isolated zones — Trusted, IoT, and Guest — built entirely in VMs with no managed switch: an 802.1Q trunk into pfSense, per-VLAN subnets and DHCP, and least-privilege rules a single client proves by changing one tag. Includes the read-only Virtual Network Editor bug that silently ate every DHCP lease.\nRead: Virtual VLAN Segmentation on pfSense — Three Isolated Zones, No Managed Switch →\nBiggerBang — SPT mod port (TypeScript → C#) An abandoned Single Player Tarkov trader mod, ported from the SPT 3.x TypeScript API to a compiled C#/.NET DLL for SPT 4.0 — five bugs fixed, released to the community.\nRead: Porting a TypeScript Game Mod to C# and Hardening It for Community Release →\nFoxWeaponSound — SPT mod port via Claude Fable 5 A 2022 JavaScript weapon-sound mod rebuilt as a .NET 9 DLL for SPT 4.0.13, driven end-to-end through Claude Fable 5 — which surfaced five bugs the original had shipped with for four years.\nRead: Porting a Mod Through Claude Fable 5 →\nSpec Grabber — system info desktop tool A native desktop GUI (Python, customtkinter, psutil) that collects system and hardware info into a styled, self-contained HTML report. Security-hardened; packaged for Linux and Windows with PyInstaller.\nRead: Created a SystemInfo Grabber Program with Claude AI →\nLaMetric TIME on an isolated network Live home-lab stats on a LaMetric Time display, bridged over MQTT through a cloud broker — the publisher VM and the display can\u0026rsquo;t reach each other on the LAN by design, so both connect outbound and the isolation stays intact.\nRead: Implementing LaMetric TIME to Network →\nLaMetric display — real WAN throughput over SNMP A follow-up that polls the lab\u0026rsquo;s pfSense firewall over SNMP for real WAN in/out rates and adds them to the display — reading a host\u0026rsquo;s own gateway without breaking the isolation, plus the counter that proves the numbers are live.\nRead: Implementing LaMetric TIME to Network Part 2 →\nPrometheus + Grafana observability stack The dashboard layer under the LaMetric glance: Prometheus scraping the Debian VM and the pfSense firewall, Grafana drawing live graphs with history — all on one isolated VM, bound to localhost, reusing the SNMP work from the previous project.\nRead: Building a Prometheus and Grafana Observability Stack for My Home Lab →\nCross-device Claude Code sync — MCP hub over Tailscale A custom MCP server on the home-lab VM that a roaming laptop\u0026rsquo;s Claude Code can push notes to from any network — linked over a Tailscale mesh with no inbound ports opened, a deliberately narrow tool surface, and the lab\u0026rsquo;s isolation left intact.\nRead: Syncing Claude Code Across Devices with a Custom MCP Hub over Tailscale →\nHardening the Debian lab VM An honest security audit of my own automation VM — even behind pfSense — and the fixes: automatic updates, cutting exposed services, an nftables default-deny firewall with SSH and the MCP hub reachable only over Tailscale, and systemd sandboxing for the custom daemons.\nRead: Hardening My Debian Home-Lab VM — Even Behind pfSense →\nThis blog Hugo + PaperMod on GitHub Pages with a Claude Code publishing pipeline: raw notes in, finished post out — filed, committed, and deployed by one command.\nRead: Self-Hosting a Hugo Blog with a Claude Code Publishing Pipeline →\n","permalink":"https://billalrehmani.pages.dev/projects/","summary":"\u003cp\u003eReal work on my own gear, written up honestly — including what broke. Each project links to its full post.\u003c/p\u003e\n\u003ch2 id=\"pfsense-containment-lab\"\u003epfSense containment lab\u003c/h2\u003e\n\u003cp\u003eA pfSense VM firewalling the Debian VM where my Claude Code agents run — egress rules allow internet-out and block every private range. Includes diagnosing a full outage caused by a Windows update silently re-enabling the hypervisor.\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"/posts/pfsense-lab-recovery-and-hardening/\"\u003eRead: Debugging a Dead VMware NAT and Hardening My pfSense Containment Lab →\u003c/a\u003e\u003c/p\u003e","title":"Projects"}]