<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://younis.ai/</id>
  <title>Loai Younis</title>
  <link href="https://younis.ai/"/>
  <link rel="self" href="https://younis.ai/feed.xml"/>
  <updated>2026-10-13T04:36:06Z</updated>
  <entry>
    <id>https://younis.ai/lms-pilot-rollout/</id>
    <title>Learning Management System Pilot — Deployment to Full Reconciliation</title>
    <link href="https://younis.ai/lms-pilot-rollout/"/>
    <updated>2026-10-08T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Oct 08, 2026 18:30 UTC</p>
<h3>Learning Management System Pilot &mdash; Deployment to Full Reconciliation</h3>

<p>Rolled out a pilot of <a href="https://moodle.org">Moodle</a>, an open-source learning management system, for one school branch within a larger multi-campus education group &mdash; the same self-hosted-first instinct behind <a href="/hr-automation-concept/">the HR automation project</a>, applied to the classroom side instead of HR records. This write-up covers the build, a couple of real near-misses, and the slow process of turning a set of empty named containers into an actually-correct roster.</p>

<p><strong>The stack.</strong> Moodle deployed via <a href="https://www.docker.com">Docker</a> Compose (the app container, a <a href="https://www.postgresql.org">PostgreSQL</a> database, and Moodle's own background cron) on a dedicated VPS from a cloud provider, isolated from other infrastructure the same way the HR system's VPS is &mdash; a pattern that's becoming the default for anything holding real personal data belonging to the organization rather than personal infrastructure.</p>

<p><strong>First pass at structure.</strong> Built a category tree mirroring the school's own hierarchy &mdash; a parent organization category, a branch category for the pilot campus, then one category per grade level, with one course shell per class section underneath. Each shell got a one-line summary (homeroom teacher, roster size) pulled from an existing attendance-tracking database already in use elsewhere for staff reporting.</p>

<p><strong>The data-freshness problem, caught before it mattered.</strong> That attendance database turned out to be stale &mdash; months out of date, reflecting the previous school year's class assignments. A second, live source existed (an online registration system, current to the day) but only had grade-level data, no section/homeroom detail, and the school's academic year had just rolled over with a full promotion cycle &mdash; so the class-shell structure built from the stale source was almost certainly wrong for anyone it actually got used on. Caught this before any real student or teacher account existed, which made it a non-event rather than a cleanup job: nothing got bulk-imported until an actual current section list arrived directly from the school.</p>

<p><strong>Branding, and a CMS surprise.</strong> Set the site logo and a short beta-announcement banner on the login page by writing a small one-off script that runs through Moodle's own bootstrap, using the same file-storage and config APIs its admin settings UI uses internally rather than touching the database directly. Updating the brand color later (to match the real logo rather than a generic placeholder blue) surfaced something unexpected: this Moodle release ships a built-in <em>AI-generated login background image</em> feature that overrides a plain brand color by default, complete with a small "AI-generated image" watermark &mdash; independent of, and silently overriding, the normal background-image admin setting. Had to explicitly suppress both the generated image and its watermark via CSS, on top of the actual color change. Also ran into Moodle's theme cache being revision-keyed rather than content-hashed: rapid edits within the same second can serve a stale cached stylesheet even after an explicit cache-clear, because the revision number didn't actually increment. Verified the fix by curling the compiled stylesheet directly rather than trusting a reload.</p>

<p><strong>The bulk import.</strong> Once a real, current section list arrived from the school, ran a one-off script against Moodle's own user-creation and enrolment APIs (not raw SQL, to keep role assignments and enrolment records internally consistent) to create every teacher and student account at once &mdash; several dozen teachers with real school email addresses, and well over a thousand students who had no email or ID in the source file at all. Students got a placeholder email address and a sequential temporary ID standing in for their real school-issued student ID, a shared temporary password, and a forced password-change on first login. The sequential placeholder ID was deliberate: a stable join key to swap in the real student ID later without having to re-match names from scratch.</p>

<p><strong>Role assignment doesn't work the way it looks like it should.</strong> Asked to grant all homeroom teachers broader access across the whole grade-level category (not just their own class), which led to a genuinely useful discovery: Moodle enforces actual course enrolment before a <em>student</em> can see anything, so a broad role grant for students is functionally inert &mdash; but for <em>teachers</em>, edit-capable roles cascade through context inheritance regardless of enrolment, so the same kind of broad grant gives real, live edit access to every class in the category, not just the teacher's own. Walked this through before it became a problem, and rolled the broad grant back to each teacher's own class only, once the asymmetry was clear. Also spent a while mapping where Moodle actually surfaces role-assignment screens for each scope (system-wide vs. category vs. course) &mdash; the category-level one in particular only appears on one specific page, not on the main course-management screen, something not obvious from the UI and only confirmed by reading the admin interface's own navigation source.</p>

<p><strong>ID reconciliation, in rounds.</strong> The school sent real student IDs to replace the sequential placeholders, in a spreadsheet that was row-aligned with the original import order &mdash; useful, but not perfect: a meaningful chunk of rows had either a literal "not found" value or the same ID assigned to two different students (mostly sibling pairs, evidently a copy-paste error on the school's side). Flagged the problem rows back to the school rather than guessing, applied the clean majority, and left the rest on their placeholder IDs. Took three more rounds of corrected data from the school &mdash; each one closing most but not all of the remaining gaps, including one case where a freshly-issued "fixed" ID for one student collided with a completely different student's existing one &mdash; before every account finally carried its real school ID. One extra case surfaced during this process: a student who appeared on the school's own roster but had never been included in any data file sent so far, not a duplicate or typo, just a genuine gap in what had been exported. Created that one account directly rather than waiting on another file.</p>

<p><strong>Restructuring from one course per class to one category per class.</strong> The original "one course shell = one class" model stopped making sense once actual subject-level courses needed to exist underneath each class rather than one container representing the whole thing. Converted every class from a single course into its own category containing one course per subject, carrying over its existing roster and teacher onto each new subject course rather than re-enrolling from scratch. Piloted the conversion on a single class first, got it checked against the live site before running the same logic as a batch across every remaining class. The batch run picked up one extra class that had quietly changed from hidden to visible between the pilot and the batch run, for reasons unrelated to this work &mdash; caught and flagged rather than silently absorbed, since it held a couple of pre-existing unrelated test accounts.</p>

<p><strong>Real curriculum, replacing a four-subject placeholder.</strong> The initial per-class subject split used a generic stand-in list; once the school sent its actual curriculum breakdown by grade band, rebuilt every class's subject courses from that instead &mdash; different grade bands get different subject counts and names, with a couple of subjects deliberately merged or substituted depending on band per direct confirmation from the school rather than assumption. One subject area stayed intentionally empty of students (teacher-only) pending a separate roster that hadn't arrived yet at that point. The rebuild ran as one script per class, old placeholder subjects deleted and real ones created in the same pass, verified against the database directly afterward rather than trusting the script's own run-time output &mdash; it ran long enough that the process-monitoring layer briefly reported it as stalled when it had, in fact, finished correctly.</p>

<p><strong>A human-readable course code.</strong> Added a structured code to every course &mdash; encoding school, grade, class section, and subject into one short identifier &mdash; after the first pass (just reusing the internal course shortname) wasn't the numeric scheme actually wanted. Exported the final mapping as a spreadsheet for the school's own reference.</p>

<p><strong>A late roster, and a casing bug.</strong> A separate roster for one specific elective subject arrived later, covering a subset of classes rather than all of them. The first enrolment pass silently under-matched &mdash; a class-label parser produced mixed-case identifiers that didn't match the system's own all-caps naming convention for most classes, so only a small fraction of the roster actually landed before the mismatch was caught by checking the enrolled count against the expected one, not by any error being thrown. Fixed the casing and re-ran; the script was written to skip anyone already enrolled, so the partial first run caused no duplicates.</p>

<p><strong>Where it stands now:</strong> every class in the pilot branch is fully structured by subject, every student and teacher account is reconciled against the school's real records with no outstanding placeholder IDs, and the elective roster is fully enrolled. Still open: a second school branch hasn't been started, and the subject-course model hasn't been tested yet against a grade band outside the pilot's own curriculum data.</p>
</div>]]></content>
  </entry>  <entry>
    <id>https://younis.ai/hr-automation-concept/</id>
    <title>HR Automation — Design Concept</title>
    <link href="https://younis.ai/hr-automation-concept/"/>
    <updated>2026-09-30T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Sep 28, 2026 19:22 UTC</p>
<h3>HR Automation — Design Concept</h3>

<p>Mapped out a design for automating HR record-keeping for an organization with 400+ staff spread across multiple sites &mdash; currently split across scattered spreadsheets and email attachments, with no reliable way to track visa, labor card, or contract expiries. This is a design only; nothing below has been implemented yet.</p>

<p>The core decision is to not build custom. <a href="https://frappe.io/hr">Frappe HR</a>, an open-source HRIS on the <a href="https://frappeframework.com">Frappe Framework</a> (Python, <a href="https://mariadb.org">MariaDB</a>, <a href="https://redis.io">Redis</a> for cache/jobs), already covers employee records, custom fields, notifications, and role-based field permissions &mdash; weeks of work to replicate from scratch. Deployment would use the official <a href="https://github.com/frappe/frappe_docker">frappe_docker</a> Compose stack (site + MariaDB + Redis + internal Nginx, managed via <code>bench</code>). Visa expiry, labor card expiry, and Emirates ID expiry become custom fields on the Employee doctype through the Customize Form UI &mdash; no code changes. Reminders ride on Frappe's built-in Notification doctype, triggered off those date fields N days out, able to call a webhook instead of just Frappe's own email/in-app alerts.</p>

<p>Hosting sits on a dedicated VPS, isolated from other infrastructure, with no public 80/443 exposed at the firewall level at all &mdash; reachable only over a <a href="https://www.wireguard.com">WireGuard</a>-based private overlay (the same protocol behind Twingate). That's network-layer isolation, not just an app login page. Data-at-rest encryption on the volume holding MariaDB's data directory sits on top of that as a second, independent layer.</p>

<p>Getting scattered data in has two paths. The base directory (name, branch, joining date) is a one-time bulk import through Frappe's own Data Import tool &mdash; CSV/Excel columns mapped to fields, no script needed. Expiry dates are the harder case, since they live on scanned documents, not structured text: the existing accounts-report pipeline already authenticates against the Microsoft Graph API and pulls email attachments for OCR, so the same auth flow and attachment handling can point at ID/visa scans instead, writing parsed dates to Frappe over its REST API (<code>PUT /api/resource/Employee/&lt;name&gt;</code>) rather than building a second ingestion path.</p>

<p>The part of the design that actually matters is the interface, because a web portal is only as good as people's willingness to log into it. Alerts and updates route through a WhatsApp group instead. Outbound: a scheduled job queries Frappe for records with an expiry inside the alert window and posts a message. Inbound: a plain-language reply (a visa renewal, a new joiner) gets parsed into a structured change, matched against the Employee list, and echoed back as a confirmation prompt &mdash; only an explicit reply triggers the actual write. Salary fields specifically sit behind Frappe's Role Permission Manager, scoped per role rather than per user, so the confirm-before-write step and the access model are two separate safeguards on the same sensitive data.</p>

<p>Threads worth pulling on further, none of it decided yet:</p>

<p><strong>Trust, not just access.</strong> Confirm-before-write stops accidental bad parses, but it doesn't stop a bad actor with WhatsApp access. Salary changes specifically might warrant a second approver rather than a single self-confirm &mdash; and every change, regardless of size, should probably land in an immutable audit log (old value, new value, who confirmed, timestamp) rather than just overwriting the field.</p>

<p><strong>Escalation, not just alerting.</strong> A single reminder N days out assumes someone reads it and acts. A tiered version &mdash; 30/14/7/1 days, escalating to a different person (branch admin &rarr; ultimately the owner) if nothing changes in the record after the first alert &mdash; turns "we sent a message" into "someone is actually accountable."</p>

<p><strong>Keep the source document, not just the extracted date.</strong> Frappe supports file attachments per record. Attaching the actual scanned visa/labor card/Emirates ID to the Employee record &mdash; not just the OCR'd expiry date &mdash; means the source document is one click away when it's time to actually renew something, instead of a date with no paper trail behind it.</p>

<p><strong>Cost forecasting.</strong> Every visa/labor card renewal has a government fee attached. A cost-per-renewal-type field, multiplied against the year's known expiries, turns this from a pure compliance tracker into a rough annual HR-compliance budget line &mdash; and it's a natural bridge to the existing <a href="https://beancount.github.io">beancount</a> ledger rather than a fully separate system.</p>

<p><strong>Onboarding and offboarding as workflows, not just directory edits.</strong> Frappe HR already ships Onboarding and Separation doctypes &mdash; checklists for what a new joiner needs (visa sponsorship steps, documents) and what a leaver triggers (clearance, final settlement). Worth using those directly instead of treating every hire/exit as a manual directory edit.</p>

<p><strong>Retention policy.</strong> Once someone leaves, their salary and ID data doesn't just get deleted &mdash; labor law typically requires records be kept for a minimum period. Worth deciding upfront what "departed staff" looks like in the schema (archived vs. deleted, and for how long) rather than retrofitting it later.</p>

<p><strong>The system itself needs a backup story.</strong> This is now infrastructure holding sensitive data on hardware separate from everything else &mdash; it needs its own place in the existing nightly backup routine, encrypted at the same standard as the live data, not an afterthought once it's running.</p>

<p><em>Updated Sep 29, 2026 18:48 UTC &mdash; a few more threads worth adding to the list above.</em></p>

<p><strong>A dedicated number for the bot, not a personal-linked one.</strong> The WhatsApp interface should run on its own number, isolated from any personally-linked account, the same way a Telegram bot has its own separate identity. That keeps the compliance-sensitive group boundaries clean and avoids a scenario where confirmations for salary changes appear to come from a personal account rather than the system.</p>

<p><strong>Don't trust every OCR read equally.</strong> The Graph-API-plus-OCR pipeline pulling expiry dates off scanned documents will occasionally misread a digit. Low-confidence extractions should route to manual review instead of flowing straight into a confirm-before-write prompt &mdash; a bad OCR read paired with a rubber-stamped confirmation is exactly how a wrong expiry date ends up as the system of record.</p>

<p><strong>Push the audit log somewhere, don't just store it.</strong> An immutable change log is only useful if someone looks at it. A periodic digest &mdash; who's expiring soon, what changed since last time &mdash; timed to line up with the existing compliance committee cadence turns the audit trail into something that actually feeds that process, instead of a table nobody queries until something goes wrong.</p>

<p><em>Updated Sep 30, 2026 10:30 UTC &mdash; VPS sizing and the agent-hosting question, now resolved.</em></p>

<p><strong>Sizing the VPS.</strong> Landed on a 4 vCPU / 8GB RAM NVMe-backed instance running <a href="https://www.debian.org">Debian</a>. At this scale the constraint isn't data volume &mdash; a few hundred employee records plus scanned documents is trivial &mdash; it's the number of always-on services a typical Frappe Docker Compose stack runs simultaneously: web server, several worker processes, a scheduler, cache, queue, and the database itself, each wanting its own slice of memory whether or not anything is actively happening.</p>

<p><strong>A second encryption layer without a boot-unlock headache.</strong> Rather than full-disk <a href="https://en.wikipedia.org/wiki/Linux_Unified_Key_Setup">LUKS</a> encryption on the boot volume &mdash; which typically means entering a passphrase on every reboot, awkward for an unattended headless box &mdash; MariaDB's data directory and the attached scanned documents live on a separate network-attached block storage volume that the provider encrypts at rest by default (AES-256), no customer-managed key or boot-time unlock step required. That satisfies the "second independent layer" from the original design without adding an operational failure mode every time the box restarts.</p>

<p><strong>Where the WhatsApp interface runs matters as much as where the database sits.</strong> The obvious shortcut was to add the WhatsApp bot as one more account on the automation gateway already running elsewhere, rather than standing up a second instance just for this. Wrong call: the isolation goal covers the whole conversational path, not only the database. Employee names, expiry dates, confirmation prompts, and the bot's own session credentials would otherwise flow through that other gateway's logs and its existing nightly backup routine &mdash; alongside unrelated personal data &mdash; which is exactly the mixing the dedicated VPS exists to prevent. Corrected design: the WhatsApp interface runs as its own agent instance, directly on the same isolated VPS as the database it talks to, with its own logs and its own backup path to the private backup target, independent of anything else.</p>

<p><strong>Extending an existing agent, not inventing a third one.</strong> There's already a sandboxed, read-only business-automation agent running elsewhere with its own WhatsApp binding. Rather than designing a new agent identity from scratch for this, that existing one gets extended into the new environment: a fresh instance on the isolated VPS, a newly assigned WhatsApp number (not yet provisioned), and write access to Frappe's API layered in behind the confirm-before-write step described above &mdash; a capability upgrade on a known, already-sandboxed agent rather than a fourth thing to reason about.</p>

<p><em>Updated Oct 08, 2026 UTC &mdash; the design moved from concept to a running system over the following days.</em></p>

<p><strong>From design to a running system.</strong> Deployed on the isolated VPS described above, running in <a href="https://www.docker.com">Docker</a>. The employee directory came in from several spreadsheets, one per site, each with its own column layout &mdash; so the import needed per-sheet header detection rather than one fixed column mapping. Recovering expiry dates from the same source required reading formula-evaluated values rather than raw cell contents, since several sheets computed dates via formulas instead of storing a literal date.</p>

<p><strong>Salary reconciliation, done in passes.</strong> Matching a separate payroll export against the directory went exact-match first, then fuzzy name-matching with manual confirmation on anything ambiguous, then a one-by-one pass on what was left &mdash; new joiners not yet in the directory, or departed staff being incorrectly reintroduced. A batch-creation bug during the original import re-scanned entire sheets instead of only the unmatched rows, creating a wave of duplicate records mixed in with genuinely new ones. Cleanup ran in two passes: a creation-timestamp cutoff plus loose token-matching for the bulk of it, then a string-similarity pass to catch the near-duplicates &mdash; typos, spacing differences, OCR-style variants &mdash; the first pass missed. Where a duplicate carried data the original lacked, that data got merged in before the duplicate was deleted, never the reverse.</p>

<p><strong>Matching gets harder than exact-or-fuzzy.</strong> A later reconciliation round needed two refinements: de-duplicating repeated name tokens before scoring similarity, and falling back to first-and-last-name matching when a record has no middle name rather than failing the comparison outright. A dedicated check &mdash; independent of the overall similarity score &mdash; caught a same-surname, different-person collision that would otherwise have applied one person's pay figure to someone else's record; the overall score alone scored it high enough to pass. A handful of genuine gaps, people absent from payroll data across two consecutive months, went to manual follow-up rather than being force-matched at a lower confidence threshold &mdash; in keeping with the original design principle of flagging low-confidence results instead of auto-applying them.</p>

<p><strong>Two payroll snapshots, not one replacing the other.</strong> A later month's payroll file used a different column layout than the first &mdash; no net-pay column; gross had to be computed from basic pay plus allowances instead &mdash; so rather than overwrite the first month's figures, both now sit side by side as separate snapshots, useful for cross-checking one month against another rather than trusting either in isolation.</p>

<p><strong>Housekeeping on the record layout.</strong> Reorganized the custom fields so salary and employment-status data live under their logical sections instead of one catch-all block, and hid &mdash; not deleted &mdash; the fields that weren't part of daily use. The reordering needed the platform's own form-customization mechanism rather than a direct field edit; a direct edit silently misplaced one block while appearing to work on another.</p>

<p><strong>The interface needed its own isolated agent, not just its own database.</strong> Resolved the open question from the design above: rather than adding one more account to the automation agent already running elsewhere, the messaging interface now runs as a fully separate agent instance, directly on the isolated VPS, through its own subscription login with a different LLM provider than the main instance &mdash; different enough to count as a genuinely separate environment, not a second account on a shared one. It has its own persona, deliberately exacting and procedural in tone given what it handles, and inherited the standing caution rules (read-only on sensitive config, prefer-safe-delete, ask-first on infrastructure changes) from the main assistant's own rulebook, for consistency.</p>

<p><strong>Messaging landed on a different platform than planned first.</strong> <a href="https://www.whatsapp.com">WhatsApp</a> pairing got as far as a scannable QR code before being deferred. In the meantime, a <a href="https://telegram.org">Telegram</a> bot connection went live instead &mdash; far simpler to wire up without an interactive pairing flow &mdash; so the alerts below are flowing there today, with WhatsApp still the eventual target.</p>

<p><strong>The confirm-before-write mechanism, for real.</strong> A small queue now sits between the agent and the database: a proposed change gets recorded but not written, requires one confirmation for most fields and two independent confirmations for salary changes specifically, and only writes through the API once that threshold is met. Tested end-to-end against real records, including the two-confirmation salary path, before going live &mdash; a genuine gate the agent can't bypass unilaterally, not just an instruction it's been told to follow.</p>

<p><strong>Alerts and a weekly digest, both live.</strong> Tiered reminders (30/14/7 days out, plus already-expired) run daily across every tracked expiry type, de-duplicated so the same alert doesn't repeat but does escalate if it crosses into a more urgent tier. The first real run surfaced a meaningful backlog of already-expired records, some stale for years &mdash; reported plainly rather than softened, which is the tone the whole system is built around. A weekly digest adds compliance counts, pending-queue activity, and a genuine audit trail pulled from the platform's own built-in version history rather than a separately bolted-on log.</p>

<p><strong>Still open:</strong> an escalation contact beyond the primary reviewer, a private network path to reach the new instance's dashboard remotely, a backup routine for this instance's own data, and the WhatsApp migration itself.</p>
</div>]]></content>
  </entry>  <entry>
    <id>https://younis.ai/homelab-update-radicale/</id>
    <title>Homelab Update</title>
    <link href="https://younis.ai/homelab-update-radicale/"/>
    <updated>2026-08-30T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Aug 30, 2026</p>
<h3>Homelab Update</h3>

<p>A series of maintenance tasks across the homelab this week. The main items were getting the book library fully operational after a migration, auditing the backup chain, and cleaning up the monitoring setup. Nothing was on fire — this was preventive work.</p>

<p><strong>1. Book library migration</strong></p>

<p>The book library (<a href="https://github.com/gotson/komga">Grimmory</a>) was moved to server2 as a <a href="https://www.docker.com">Docker</a> container backed by <a href="https://mariadb.org">MariaDB</a>. The migration involved pulling the entire book collection from server1's backup storage — about 632 MB of ebooks and comics. The container picked up most books automatically, but comics in <code>.cbz</code> format needed their permissions fixed before the library scanner would index them.</p>

<p>The <a href="https://mariadb.org">MariaDB</a> database holds all metadata: reading progress, tags, collections, and cover art cache. Initially excluded from backups to avoid complexity, but it's only 155 MB and rsync handles it fine. Added it to the nightly backup — won't break the running database because rsync only reads, and at 4 AM the DB is idle.</p>

<p><strong>2. Document management</strong></p>

<p>Set up <a href="https://github.com/icereval/papra">Papra</a> for document management at <code>docs.example.com</code>. Another Docker container, this one simpler — just needs a data directory for uploaded files. <a href="https://caddyserver.com">Caddy</a> handles the reverse proxy and TLS termination. The DNS record was added to the zone on server1 and propagated to server2 via <a href="https://en.wikipedia.org/wiki/DNS_zone_transfer">AXFR</a>.</p>

<p><strong>3. Monitoring consolidation</strong></p>

<p><a href="https://uptime.kuma.pet">Uptime Kuma</a> was set up at <code>kuma.example.com</code> on server2. It monitors all public services — the book library, document manager, and the main website. It also monitors server1's web endpoint to verify it's serving pages. Alerts go to <a href="https://telegram.org">Telegram</a>. The container runs as user 1000:1000 (non-root) as part of the security hardening done earlier.</p>

<p><strong>4. Backup chain audit</strong></p>

<p>Reviewed the full backup topology. Two daily crons on server2: <code>backup-books-to-server1</code> at 04:00 (books only, with <code>--delete</code>) and <code>backup-sync</code> at 04:10 (entire home directory, without <code>--delete</code>). The books get backed up twice — once standalone, once as part of the full sync. Redundant but harmless. The standalone job uses <code>--delete</code> to remove books from server1 that were deleted locally; the full sync doesn't, which means deleted files persist on server1 until manually cleaned.</p>

<p>Total backup footprint on server1 is about 1.7 GB. Backup logs go to <code>~/journal/homelab-backup/</code> with dated filenames for easy review.</p>

<p><strong>5. CalDAV status</strong></p>

<p><a href="https://radicale.org">Radicale</a> on server1 is running again after the last <code>pkg_add -u</code> update. It handles <a href="https://en.wikipedia.org/wiki/CalDAV">CalDAV</a> and <a href="https://en.wikipedia.org/wiki/CardDAV">CardDAV</a> for calendar and contact sync. The service runs behind <a href="https://man.openbsd.org/relayd.8">relayd</a> on port 5232. No issues after the package update — just needed a service restart.</p>

<p><strong>6. Homelab health snapshot</strong></p>

<p>Current state of all machines: the router is stable (25 days uptime, low load). <a href="https://www.home-assistant.io">Home Assistant</a> running clean (7 days since last restart, 42% disk). The NAS root disk is at 85% — worth monitoring but not critical since the data volumes have plenty of space. The MacBook is at 58 days uptime with only 6% disk usage. Both VPS instances healthy with low load.</p>

<p><strong>7. What's next</strong></p>

<p>The NAS disk usage needs attention before it hits 90%. The duplicate book backup job could be consolidated into the main sync with <code>--delete</code>, but that's a low-priority cleanup. The monitoring setup is complete — any service going down triggers a Telegram alert within 60 seconds.</p>
</div>]]></content>
  </entry>  <entry>
    <id>https://younis.ai/dns-migration-dnssec/</id>
    <title>DNS Migration & DNSSEC</title>
    <link href="https://younis.ai/dns-migration-dnssec/"/>
    <updated>2026-08-26T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Aug 26, 2026</p>
<h3>DNS Migration &amp; DNSSEC</h3>

<p>I run two VPS instances. server1 is an <a href="https://www.openbsd.org">OpenBSD</a> box that serves static files and acts as my secure anchor. server2 is a <a href="https://www.debian.org">Debian</a> machine that runs containers and does the heavy lifting. The goal was to build a <a href="https://en.wikipedia.org/wiki/Domain_Name_System">DNS</a> architecture where server1 is the hidden master — it holds the authoritative zone file but never appears in public NS records. Public queries hit server2 and a cloud provider's secondary nameserver. server1's IP stays out of DNS entirely.</p>

<p><strong>1. The architecture</strong></p>

<p>server1 runs <a href="https://www.nlnetlabs.nl/projects/nsd">NSD</a> as the hidden master. The zone file lives at <code>/var/nsd/zones/master/</code> and contains all records for the primary domain. server2 also runs NSD, configured as a secondary that pulls from server1 via <a href="https://en.wikipedia.org/wiki/DNS_zone_transfer">AXFR</a> (zone transfer). A cloud provider acts as the third nameserver, synced manually with a script.</p>

<p>The nameserver delegation at the registrar points to <code>ns1.primarydomain</code> (server2) and the cloud provider's NS. server1 is nowhere in the public chain — it only speaks to server2 over a private AXFR channel.</p>

<p><strong>2. NSD configuration on server1</strong></p>

<p>NSD on <a href="https://www.openbsd.org">OpenBSD</a> is straightforward. The config at <code>/var/nsd/etc/nsd.conf</code> defines the zone and allows AXFR transfers only to server2's IP. The zone file follows standard <a href="https://en.wikipedia.org/wiki/Zone_file">RFC 1035</a> format with SOA, NS, A, CNAME, and MX records. Every edit requires incrementing the serial number — I use the YYYYMMDDNN convention so it's always monotonically increasing.</p>

<p>After editing, <code>doas rcctl reload nsd</code> picks up the changes. server2 polls for SOA serial changes and triggers AXFR automatically when it detects a higher serial.</p>

<p><strong>3. NSD configuration on server2</strong></p>

<p>server2's NSD is configured as a secondary with <code>request-xfr</code> pointing to server1. It stores the received zone in <code>/var/lib/nsd/</code>. The refresh interval is set to 3600 seconds (1 hour), but NOTIFY messages from server1 trigger immediate transfers for most changes.</p>

<p><strong>4. Cloud provider sync</strong></p>

<p>The cloud provider doesn't support AXFR inbound, so I wrote <code>~/bin/sync-dns-to-cloud</code> — a shell script that reads the zone file, parses it, and pushes records via the provider's API. It handles A, AAAA, CNAME, MX, and TXT records. Not ideal, but it only needs to run when I change the zone, which is rare.</p>

<p><strong>5. Zone management workflow</strong></p>

<p>The workflow for any DNS change is: (1) SSH to server1, (2) edit the zone file and increment the serial, (3) <code>doas rcctl reload nsd</code>, (4) server2 picks it up via AXFR within seconds, (5) run the sync script for the cloud provider. Three steps if I skip the cloud sync for records that don't need geographic redundancy.</p>

<p><strong>6. DNSSEC signing</strong></p>

<p>After the base DNS was working, I signed the zone with <a href="https://www.icann.org/resources/pages/dnssec-what-is-it-why-important-2019-03-05-en">DNSSEC</a>. Generated both a Zone Signing Key (ZSK) and a Key Signing Key (KSK) using <code>ldns-keygen</code> with ECDSAP256SHA256 (algorithm 13). The ZSK signs individual records, the KSK signs the DNSKEY RRset.</p>

<p>The signing process: <code>ldns-signzone</code> takes the unsigned zone file and both keys, outputs a signed zone with RRSIG, NSEC, and DNSKEY records. I then published the DS record at the registrar — this is the trust anchor that chains the domain to the root zone. The DS record contains the KSK's key tag, algorithm, digest type, and digest hash.</p>

<p><strong>7. Verification</strong></p>

<p>Tested with <code>dig +dnssec</code> to confirm RRSIG records are returned, <code>delv</code> for full chain-of-trust validation, and online tools like DNSViz to visualize the chain. All checks passed — the zone validates from root to leaf.</p>

<p><strong>8. Why this architecture</strong></p>

<p>The hidden master pattern keeps server1's IP out of public records entirely. Anyone querying DNS sees only server2 and the cloud provider. server1 is invisible to scanners, bots, and anyone enumerating the domain. This matters because server1 also handles other sensitive services — the less exposure, the better.</p>

<p>Geographic redundancy across two physical locations and a cloud provider means DNS stays up even if one goes down. And since server1 is the single source of truth, there's never a split-brain problem — changes flow in one direction only.</p>

<p><strong>9. Edge cases handled</strong></p>

<p>If server1 goes down, server2 and the cloud provider continue serving the cached zone until the SOA expiry (set to 1 week). If server2 goes down, the cloud provider handles all queries. If both secondaries go down, DNS is offline but server1 is untouched and can be re-synced once connectivity returns.</p>

<p>The AXFR channel between server1 and server2 is restricted by IP in NSD's config — only server2's IP can request transfers. This prevents unauthorized zone dumps.</p>

<p><strong>10. What's not included</strong></p>

<p>I didn't set up automated DNSSEC key rotation. The current keys work indefinitely, but best practice is to rotate the ZSK quarterly and the KSK annually. That's a future task. I also didn't automate the cloud provider sync — it's manual because DNS changes are infrequent and I want to review each one before it goes live.</p>
</div>]]></content>
  </entry>  <entry>
    <id>https://younis.ai/ssh-hardening-security-audit/</id>
    <title>SSH Hardening & Security Audit</title>
    <link href="https://younis.ai/ssh-hardening-security-audit/"/>
    <updated>2026-08-26T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Aug 26, 2026</p>
<h3>SSH Hardening &amp; Security Audit</h3>

<p>Both servers were running stock <a href="https://www.openssh.com">SSH</a> configs. Not terrible, but not locked down either. The goal was to eliminate password auth entirely, restrict which users can log in, rate-limit connection attempts, and close every port that doesn't need to be open. Two different operating systems meant two different approaches to the same problem.</p>

<p><strong>1. server1 (OpenBSD) — SSH config</strong></p>

<p><a href="https://www.openbsd.org">OpenBSD</a> ships with a conservative sshd by default, but I tightened it further. In <code>/etc/ssh/sshd_config</code>: <code>PasswordAuthentication no</code>, <code>PermitRootLogin no</code>, <code>AllowUsers sysadmin</code> (only one user can SSH in), <code>MaxAuthTries 3</code>, <code>ClientAliveInterval 300</code> (drop idle connections after 5 minutes), and all forwarding disabled — <code>AllowTcpForwarding no</code>, <code>AllowAgentForwarding no</code>, <code>X11Forwarding no</code>.</p>

<p><strong>2. server1 — pf rate limiting</strong></p>

<p>OpenBSD's <a href="https://www.openbsd.org/faq/pf">pf</a> firewall handles connection-level rate limiting. I added a <code>&lt;bruteforce&gt;</code> table and a rule that allows SSH connections with <code>max-src-conn-rate 5/60</code> — if any IP opens more than 5 connections in 60 seconds, it gets added to the bruteforce table and all further connections are dropped. The table is persistent across reboots.</p>

<p>The pf rules also explicitly block mail ports (25, 587, 993) since server1 no longer runs mail services. Only ports 80 and 443 are open for web traffic, plus 53 for DNS.</p>

<p><strong>3. server1 — the lockout incident</strong></p>

<p>During initial testing of the pf rate limiting, I hit a problem. The daily backup script from server2 opens multiple SSH connections to server1 in quick succession — rsync, status checks, config pulls. This triggered the rate limiter, and server2's IP got added to the bruteforce table. server2 could no longer reach server1 at all.</p>

<p>I had to access server1 through the cloud provider's web console (out-of-band management), reboot the machine to flush the pf tables, and then fix the config. The solution was adding server2's IP to a whitelist that gets evaluated <em>before</em> the rate limit rule. Order matters in pf — rules are evaluated first-to-last, and the first match wins.</p>

<p><strong>4. server2 (Debian) — SSH config</strong></p>

<p>Same sshd hardening as server1: key-only auth, no root login, <code>AllowUsers user</code>, <code>MaxAuthTries 3</code>, <code>ClientAliveInterval 300</code>. The difference is in the forwarding: server2 allows TCP forwarding because <a href="https://www.docker.com">Docker</a> needs it for container networking. Everything else is disabled.</p>

<p><strong>5. server2 — ufw + fail2ban</strong></p>

<p><a href="https://www.debian.org">Debian</a> doesn't have pf, so rate limiting uses two layers. <a href="https://help.ubuntu.com/community/UFW">ufw</a> with <code>LIMIT</code> on port 22 handles basic connection throttling — it drops connections from IPs that attempt more than 6 connections in 30 seconds. On top of that, <a href="https://www.fail2ban.org">fail2ban</a> watches <code>/var/log/auth.log</code> for failed login attempts and bans offending IPs for 10 minutes after 5 failures.</p>

<p>The fail2ban stats tell the story: 435 total bans since installation, with 2 IPs currently blocked at any given time. Most are automated scanners hitting port 22 and trying common usernames. They get exactly 5 attempts before they're firewalled.</p>

<p><strong>6. Docker non-root hardening</strong></p>

<p>While auditing the security posture, I noticed <a href="https://uptime.kuma.pet">Uptime Kuma</a> was running as root inside its container. Changed the docker-compose to run as user 1000:1000. This means even if someone exploits a vulnerability in the monitoring service, they can't escalate to root inside the container, and container breakout gives them an unprivileged user on the host.</p>

<p><strong>7. Automated config backups</strong></p>

<p>Created <code>~/bin/backup-configs</code> that runs daily at 2 AM via cron. It copies SSH configs, pf rules (server1), ufw rules (server2), <a href="https://caddyserver.com">Caddy</a> config, NSD zone files, and cron tabs to a timestamped directory. Retention is 30 days — older backups get deleted automatically. If a config change breaks something, I can diff against yesterday's backup and roll back.</p>

<p><strong>8. Automatic security patches</strong></p>

<p>server1 runs <code>/usr/local/bin/security-updates</code> weekly on Monday at 3 AM. It applies <code>syspatch</code> (OpenBSD binary patches) and runs <code>pkg_add -u</code> (package updates). server2 has <code>unattended-upgrades</code> configured for security-only updates. Both log their output for review.</p>

<p><strong>9. File integrity monitoring</strong></p>

<p><a href="https://aide.github.io">AIDE</a> (Advanced Intrusion Detection Environment) is set up on server2. It generates a baseline database of file hashes for critical system directories — <code>/etc</code>, <code>/usr/bin</code>, <code>/usr/sbin</code>. Daily checks compare current state against the baseline and report any unexpected changes. This catches unauthorized modifications to system binaries or configs.</p>

<p><strong>10. Firewall audit results</strong></p>

<p>Final port inventory after hardening. server1: 22 (SSH, rate-limited), 53 (DNS), 80/443 (web). Mail ports 25, 587, 993 explicitly blocked. server2: 22 (SSH, ufw LIMIT + fail2ban), 80/443 (web, Caddy reverse proxy), Docker bridge ports for internal container communication only. Both servers — no unnecessary services listening, no world-accessible management ports, no password auth anywhere.</p>

<p><strong>11. What's not included</strong></p>

<p>I didn't set up intrusion detection on server1 (OpenBSD has its own integrity checking via <code>mtree</code>, which I haven't configured yet). I also didn't implement SSH certificate auth — key-based is sufficient for two servers with one admin each. Port knocking was considered and rejected as unnecessary complexity given the rate limiting already in place.</p>
</div>]]></content>
  </entry>  <entry>
    <id>https://younis.ai/disaster-recovery-bootstrap/</id>
    <title>Disaster Recovery Bootstrap</title>
    <link href="https://younis.ai/disaster-recovery-bootstrap/"/>
    <updated>2026-08-26T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Aug 26, 2026</p>
<h3>Disaster Recovery Bootstrap</h3>

<p>The question was simple: if server2 dies tomorrow — hardware failure, provider goes down, accidental destruction — how fast can I rebuild it from zero? The answer before this work was "hours of manual setup, hoping I remember everything." The answer now is one script and about 20 minutes of automated restoration.</p>

<p><strong>1. The problem</strong></p>

<p>server2 runs the entire operational stack: <a href="https://www.docker.com">Docker</a> containers for a book library, document management, and monitoring. A <a href="https://caddyserver.com">Caddy</a> reverse proxy with automatic <a href="https://letsencrypt.org">Let's Encrypt</a> certificates. 40+ custom shell scripts. Cron jobs for backups, reports, and maintenance. <a href="https://openclaw.ai">OpenClaw</a> for automation. Git repos for journal and financial ledger. Vim config with custom key mappings and <a href="https://beancount.github.io">beancount</a> shortcuts. All of it backed up nightly to server1, but no automated way to put it all back together.</p>

<p><strong>2. The backup system</strong></p>

<p>A daily cron at 04:10 UTC runs <a href="https://rsync.samba.org">rsync</a> from server2 to server1, syncing the entire home directory to <code>/mnt/vps-backup/</code>. Excludes are minimal — just package caches and virtualenvs that can be regenerated. A separate cron syncs the book library at 04:00. Total backup size is around 1.7 GB. This means server1 always has a &lt;24 hour old copy of everything on server2.</p>

<p><strong>3. What the bootstrap script does</strong></p>

<p>The script assumes a fresh <a href="https://www.debian.org">Debian</a> VPS with root access and an <a href="https://www.openssh.com">SSH</a> key that can reach server1. It runs in sequence:</p>

<p><strong>4. System packages</strong></p>

<p>Installs everything server2 needs: Docker and docker-compose, <a href="https://www.python.org">Python</a> 3 with pip, <a href="https://nodejs.org">Node.js</a> (for OpenClaw), <a href="https://git-scm.com">git</a>, rsync, vim, fail2ban, ufw, and build essentials. Python packages include beancount, whisper, and their dependencies. The package list was generated by diffing a fresh Debian install against server2's current state.</p>

<p><strong>5. Pull from backup</strong></p>

<p>rsync pulls the entire <code>/mnt/vps-backup/</code> from server1 back to the new machine's home directory. This restores all scripts, configs, dotfiles, journal, financial ledger, memory files, and application data in one shot. The SSH key to server1 is the only prerequisite — everything else flows from the backup.</p>

<p><strong>6. Restore Docker services</strong></p>

<p>The docker-compose files are part of the backup, so restoration is: navigate to each service directory and run <code>docker-compose up -d</code>. The book library needs its MariaDB data directory (included in the backup), the document manager needs its uploaded files (included), and the monitoring tool needs its SQLite database (included). Caddy config is also in the backup — it picks up the reverse proxy rules automatically.</p>

<p><strong>7. Restore cron jobs</strong></p>

<p>A <code>crontab.backup</code> file is maintained in the home directory and synced to server1 daily. The script loads it with <code>crontab crontab.backup</code>. This restores all 14 scheduled jobs — backup syncs, daily digests, school reports, financial reports, memory maintenance, and journal sync.</p>

<p><strong>8. Fix git remotes</strong></p>

<p>The journal and finance repos have origin URLs pointing to GitHub. After restoration, git pull/push works immediately because the SSH keys (for GitHub) are part of the home directory backup. The script verifies each repo can reach its remote.</p>

<p><strong>9. Install OpenClaw</strong></p>

<p>OpenClaw is installed via npm. The config, session history, cron state, and memory files are all in the backup. After installation, the agent resumes with full context — it knows the infrastructure, the user's preferences, and all scheduled tasks.</p>

<p><strong>10. Validation</strong></p>

<p>A separate validation script runs 44 checks across every component: Docker services responding on expected ports, cron jobs loaded, SSH key access to server1 working, git repos in clean state, all 40 bin scripts present and executable, Caddy serving HTTPS, DNS resolving correctly, fail2ban running, backup cron scheduled. Each check prints pass/fail with details.</p>

<p><strong>11. Pre-restore checklist</strong></p>

<p>A markdown checklist documents what to do <em>before</em> running the script: provision a new VPS at the same provider, ensure the SSH key to server1 is available (it's also backed up to an encrypted local copy), update DNS if the IP changes, and verify server1's backup is current.</p>

<p><strong>12. Current system snapshot</strong></p>

<p>The script also generates and maintains <code>current-state.txt</code> — a snapshot of every running service, open port, installed package, cron job, and disk usage. This serves as both documentation and a restoration target. If the rebuilt system doesn't match the snapshot, something was missed.</p>

<p><strong>13. What's protected</strong></p>

<p>Everything needed for full functionality: Docker services and their persistent data (including the MariaDB database for the book library). All 40+ custom scripts in <code>~/bin/</code>. The complete <a href="https://beancount.github.io">beancount</a> financial ledger. Vim configuration with custom key mappings (<code>,j</code> for journal, <code>,b</code> for beancount, <code>,B</code> for syntax check, <code>,r</code> for reports). Bash configuration with aliases and functions. SSH keys for all remote access. OpenClaw configuration, session history, and memory. Git repositories with full history.</p>

<p><strong>14. What could go better</strong></p>

<p>The MariaDB backup is a filesystem copy, not a proper dump. This works because rsync runs at 4 AM when load is minimal and the database is small (155 MB), but a concurrent write during backup could produce a corrupted copy. A proper fix would be a pre-backup hook that runs <code>mysqldump</code> first. I haven't implemented this because the window for corruption is small and the data (book metadata) is recoverable from the books themselves if needed.</p>

<p>DNS propagation after an IP change can take up to 48 hours. The script can't control that. If speed matters, I'd need to have the cloud provider's API update DNS records as part of the bootstrap — which is scriptable but adds a dependency on API credentials being available outside the backup.</p>
</div>]]></content>
  </entry>  <entry>
    <id>https://younis.ai/beancount-finance-tracking/</id>
    <title>Finance Tracking with Beancount</title>
    <link href="https://younis.ai/beancount-finance-tracking/"/>
    <updated>2026-08-15T00:00:00Z</updated>
    <content type="html"><![CDATA[<div class="entry">
<p class="timestamp">Aug 15, 2026</p>
<h3>Finance Tracking with Beancount</h3>

<p>I've been tracking personal finances in <a href="https://beancount.github.io">beancount</a> for a while now, but the tooling around it has evolved significantly. The ledger file lives in a private git repo, and I've built a set of scripts and automation around it that make the whole system practical for daily use.</p>

<p><strong>1. The ledger</strong></p>

<p>Everything lives in <code>~/finance/accounts.beancount</code> — a plain-text double-entry ledger. Operating currency is AED, with CAD positions tracked separately for a Canadian brokerage. The file follows beancount's syntax: account declarations at the top, then chronological transactions. Each transaction has a date, payee, narration, and at least two postings that sum to zero.</p>

<p><strong>2. Account structure</strong></p>

<p>The chart of accounts covers: checking accounts (AED and CAD), credit cards, brokerage accounts (AED equities, CAD positions in three funds), a family member's managed portfolio, and expense categories broken down by type — groceries, dining, transport, subscriptions, services, and so on. Income tracks salary and investment returns separately.</p>

<p><strong>3. Vim integration</strong></p>

<p>Since I edit the ledger in <a href="https://www.vim.org">Vim</a>, I set up key mappings and abbreviations to speed up entry. <code>,b</code> opens the ledger, <code>,B</code> runs <code>bean-check</code> for syntax validation, <code>,r</code> runs the report script. Abbreviations expand common accounts: <code>expg</code> becomes <code>Expenses:Food:Groceries</code>, <code>ac</code> becomes <code>Assets:Bank:Checking</code>, <code>lcc</code> becomes <code>Liabilities:Credit:Card</code>. These are defined in <code>.vimrc</code> and make manual entry fast enough that I don't need a separate input tool.</p>

<p><strong>4. Reporting scripts</strong></p>

<p>Two main scripts handle reporting. <code>~/bin/networth</code> is the original <a href="https://www.python.org">Python</a> script that parses the ledger and outputs a detailed breakdown — assets by account, liabilities, net position, and month-over-month change. <code>~/bin/networth-report</code> is a newer script that generates markdown reports for automated delivery. It combines net worth and budget data into a single document.</p>

<p>The budget section compares actual spending against caps defined in the script. Categories with caps: groceries (3,000), dining (3,000), services (2,000), subscriptions, gas, personal care. When spending exceeds a cap, the report flags it. The total budget target is around 19,000 AED/month.</p>

<p><strong>5. Automated monthly reports</strong></p>

<p>Three cron jobs generate reports: on the 1st (previous month final), the 15th (mid-month check), and the last day (month-end snapshot). Each runs the report script and sends the output to <a href="https://telegram.org">Telegram</a>. This means I get a financial summary delivered three times a month without doing anything.</p>

<p><strong>6. Statement reconciliation</strong></p>

<p>When bank statements come in, I diff them against the ledger to find discrepancies. This has caught missing transactions — a gas station charge here, an interest charge there. The process is manual but fast: export the statement, compare line by line, add anything missing. Beancount's <code>bean-check</code> validates the result.</p>

<p><strong>7. Multi-currency handling</strong></p>

<p>The AED ledger is straightforward, but the CAD positions add complexity. Beancount handles multiple currencies natively — each posting specifies its currency, and the price directive sets exchange rates for net worth calculations. The CAD brokerage holds positions in three funds, each tracked as a separate commodity. Monthly investment targets are defined separately and checked against actual contributions.</p>

<p><strong>8. Git versioning</strong></p>

<p>The finance directory is its own <a href="https://git-scm.com">git</a> repo, pushed to a private remote. Every edit gets committed — the <code>gitpush</code> function in <code>.bashrc</code> handles journal, finance, and dotfiles in one command. This gives full history of every transaction ever added, modified, or corrected. If I fat-finger an amount, <code>git diff</code> shows exactly what changed.</p>

<p><strong>9. What works well</strong></p>

<p>Plain text means the ledger is greppable, diffable, and version-controlled. No vendor lock-in, no subscription, no cloud dependency. The Vim shortcuts make entry as fast as any GUI. Automated reports mean I actually look at the numbers regularly instead of ignoring them. The git history provides a complete audit trail.</p>

<p><strong>10. What's not included</strong></p>

<p>I don't use <a href="https://beancount.github.io/fava/">fava</a> (the web UI for beancount) — the terminal workflow is sufficient for now. I don't have automated bank import — transactions are entered manually, which is actually a feature since it forces me to review each one. I don't track individual stock lots for tax purposes since the jurisdictions I operate in don't have capital gains tax on securities.</p>
</div>]]></content>
  </entry>
</feed>