WRKLOG-2026-06-28

Summary

Sunday. Fill summary at end of day.

URGENT - All Domains

Carryover Backlog (CRITICAL)

Task Details Origin Days Status

MSCHAPv2 Migration Report

Report due. 6-sheet Standard Report (exec summary, trend, waves, device detail, stale, policy match). Sheet 6 added 05-14: policy match by protocol for removal planning + anonymous identity validation. Migration window 2026-05-04 to 2026-05-30. ~6,227 devices, 5 waves.

2026-04-17

83

P0 - DUE — run report this week

Abnormal Security — ✅ COMPLETE

CR-2026-05-07-abnormal-read-write. CAB approved 2026-05-12. Implemented successfully 2026-05-13. Read/write enabled for pilot group. Post-deployment validation pending.

2026-05-07

63

✅ IMPLEMENTED — post-validation pending

SIEM QRadar → Sentinel Migration

Lead role. Monad console error RESOLVED 2026-05-12 — secrets configured in CHLA production tenant. ISE secure syslog integration in progress — cert imported, remote logging target configured, streaming errors under investigation. Blocking: DCR not created (Rule ID + Stream Name). Azure private network policy unresolved. Victor + Mauricio action.

2026-04-10

90

P0 - ACTIVE — ISE syslog + DCR blocking

Monad Pipeline Evaluation

Sentinel output connector. Console error resolved. 3 of 6 values configured. Remaining: Endpoint URL (have it), Rule ID + Stream Name (need DCR). ISE Remote Logging Target configured 2026-05-18 — TLS cert imported, secure syslog target created. Streaming errors in Monad console under investigation.

2026-03-11

120

P0 - ACTIVE — ISE integration in progress

Guest Redirect ACL

Guest redirect ACL work needed. Related to Mandiant remediation findings.

2026-05-12

58

P0 - TODO

ISE Patch 10 (CVE-2026-20147 CVSS 9.9)

ISE 3.2 Patch 10. Supersedes Patch 9. 61 days on a CVSS 9.9 — schedule maintenance window. Write CR if needed.

2026-03-12

119

P0 - OVERDUE — schedule immediately

k3s NAT verification

NAT rule 170 for 10.42.0.0/16 pod network - test internet connectivity. 64 days — test this week or defer to Q3.

2026-03-09

122

P0 - BLOCKING — TRIAGE: schedule or defer

Wazuh indexer recovery

Restart pod after NAT confirmed working - SIEM visibility blocked. Blocked by k3s NAT — cannot proceed until above resolved.

2026-03-09

122

P0 - Blocked by k3s

Strongline Gateway VLAN fix

8 devices in wrong identity group (David Rukiza assigned)

2026-03-16

115

P0 - TODO

TCP Clocks deployment

ISE identity group validation, query outputs, comms with team. Active d001 data Apr 22-23.

2026-04-22

78

P0 - ACTIVE

IoT Dr. Kim — recurring

Sleep study devices (Apr 15-16), watches recurrence (Apr 22). 5 incident versions in d001. Validate iPSK enrollment.

2026-04-15

85

P0 - RECURRING

Murus Portae (WAF) — Phase 0

FMC cert expired, ACP returns zero rules. d001: zone map, architecture D2, FMC API reference, ops script.

2026-04-16

84

P0 - INVESTIGATING

Vocera EAP-TLS Supplicant Fix

~10 phones failing 802.1X, missing supplicant config. 61 days — schedule with clinical engineering team.

2026-03-12

119

P1 - TODO — schedule

ISE MnT Messaging Service

Enable "Use ISE Messaging Service for UDP syslogs delivery". 61 days — low risk, schedule with ISE Patch 10 maintenance window.

2026-03-12

119

P2 - BUNDLE with Patch 10

Professional backlog remains critical. Check Days column for priorities.

BLOCKERS — Fix Immediately

Task Details Origin Days Impact

Z Fold 7 Termux

gopass and SSH not working

2026-03-10

58

BLOCKER — Cannot access passwords on mobile

gopass v3 organization

Inconsistent structure, poor key-value usage

2026-03-20

48

Inefficient password management, no aggregation

Git history scrub — sensitive personal terms

Plaintext references to personal legal matters in committed worklogs (WRKLOG-2026-03-14, WRKLOG-2026-04-18). Forward-fixed but old commits still contain strings. Requires git filter-repo + force-push. See runbook below.

2026-04-22

15

SECURITY — sensitive terms in public git history

Runbook: Git History Scrub (d000 Personal Terms)

Problem: Two committed worklogs contained plaintext references to personal legal matters. The files have been edited (forward-fix), but git history retains the original text in prior commits.

Affected commits: Any commit touching these files:

# Identify affected commits
git log --oneline -- \
  docs/modules/ROOT/pages/2026/03/WRKLOG-2026-03-14.adoc \
  docs/modules/ROOT/pages/2026/04/WRKLOG-2026-04-18.adoc

Scrub procedure:

# 1. BEFORE: Full backup of the repo
cp -a ~/atelier/_bibliotheca/domus-captures ~/atelier/_bibliotheca/domus-captures.bak

# 2. Install git-filter-repo (if not present)
# Arch: pacman -S git-filter-repo
# pip: pip install git-filter-repo

# 3. Create expressions file for replacement
cat > /tmp/scrub-expressions.txt << 'EXPR'
regex:(?i)divorce==[REDACTED]
regex:(?i)dissolutio(?!n\.adoc\.age)==[REDACTED-LEGAL]
regex:(?i)iliana==[REDACTED-NAME]
regex:(?i)angulo-arreola==[REDACTED-NAME]
regex:legal-divorce-notes\.age==legal-notes.age
regex:1099-NEC-iliana==1099-NEC
EXPR

# 4. Verify before (dry run — count matches in history)
git log -p --all -S 'divorce' -- '*.adoc' | grep -c 'divorce' || echo "0 matches"
git log -p --all -S 'iliana' -- '*.adoc' | grep -c 'iliana' || echo "0 matches"

# 5. Run filter-repo (DESTRUCTIVE — rewrites all commit hashes)
git filter-repo --replace-text /tmp/scrub-expressions.txt --force

# 6. Verify after
git log -p --all -S 'divorce' -- '*.adoc' | grep -c 'divorce' || echo "0 matches — CLEAN"
git log -p --all -S 'iliana' -- '*.adoc' | grep -c 'iliana' || echo "0 matches — CLEAN"

# 7. Re-add remotes (filter-repo removes them)
git remote add origin git@github.com:<user>/domus-captures.git
# Add any other remotes (Gitea, etc.)

# 8. Force-push to all remotes (DESTRUCTIVE — overwrites remote history)
git remote | xargs -I{} git push {} main --force

# 9. Clean up
rm /tmp/scrub-expressions.txt
rm -rf ~/atelier/_bibliotheca/domus-captures.bak  # only after verifying

Post-scrub checklist:

  • Backup created before running

  • git filter-repo installed

  • Expressions file reviewed — no false positives (e.g., Don Quijote "Angulo el Malo" is in segunda-parte/texto/texto-011.adoc — the regex targets angulo-arreola specifically to avoid this)

  • Dry-run counts match expectations

  • Filter-repo executed

  • Post-scrub verification shows 0 matches

  • Remotes re-added

  • Force-pushed to all remotes

  • Cloudflare Pages rebuild verified

  • Local clones on other machines re-cloned or git fetch --all && git reset --hard origin/main

  • Backup removed

URGENT - Requires Immediate Action

Item Details Deadline Status Impact

Housing Search

Granada Hills area - apartments/rooms

TBD

In Progress

Quality of life, commute

2025 Tax — IRS Transcript Review

MFJ filed 2026-04-22. Pull IRS Return Transcript to verify contents. Consult attorney re: Form 8857 (Innocent Spouse Relief). Details in encrypted case file.

Before attorney meeting

In Progress

Financial — liability exposure. See encrypted D000 case file.

Rack Relocation

Physical move of server rack. CR written: CR-2026-04-18 (pending in infra-ops). Borg backup completed. VM XML dumps, switch save, shutdown/startup procedure documented.

TBD

Pending

Infrastructure downtime — all services offline during move

D000 Legal Planning

Encrypted D000 case file. Open: d000 open dissolutio. Close: d000 close dissolutio. 19 partials + assembler. PDF build for attorney handoff. Critical deadline: Jan 2029.

Before Jan 2029

Active — escalating

Life transition — see case file for details

Credit Report Review

Pull reports from all 3 bureaus via annualcreditreport.com. Verify no unknown joint accounts or debts. Credentials in gopass: v3/personal/finance/credit/annual_credit_report

TBD

In Progress

Financial discovery — FL-142 preparation

Gopass Security Audit

Rotate passwords on shared/known accounts. Add 2FA backup codes to v3/personal/recovery/. Create missing government entries (IRS, SSA, VA, DMV). Add last_login field to active entries.

TBD

Pending

Digital security — pre-filing preparation

Subscription Audit

Download 3 months bank/CC statements (Chase, NFCU, USAA). Identify all recurring charges. Cancel unnecessary. Document active subscriptions for FL-150.

TBD

Pending

Financial — expense documentation

401(k) Enrollment

Enroll in CHLA 401(k) immediately. Post-separation contributions are 100% separate property. Reduces gross income for support calculations. Max 2026: $23,500/yr.

In progress (started 5/4)

In Progress

Financial — support calculation + retirement

URGENT — Performance Review Certifications

Certification Provider Deadline Status Impact

CISSP

ISC² — Certified Information Systems Security Professional

July 12, 2026

ACTIVE — Week 2 of 10 (Project)

Required for performance review. 10-week accelerated plan.

RHCSA 9

Red Hat Certified System Administrator

Q3 2026

ACTIVE — 21-phase curriculum (Project)

After CISSP. Required for performance review.

CISSP: 41 days remaining (exam July 12). Domain 1 study in progress. Schedule exam today (06-01).

Early Morning - 5:30am

Regex Training (CRITICAL CARRYOVER)

  • Session 3 - Character classes, word boundaries

  • Practice drills from regex-mastery curriculum

  • Status: 52 days carried over (since 2026-03-16) — CRITICAL

Regex training continues to slip. This is the foundation for all CLI mastery.

Daily Notes

Triage Status

Item Status Destination

AppArmor git/gopass EACCES (INC-003) — RESOLVED

Done

comms-tshoot-apparmor-git-gopass-2026-06-27.adoc — Case 001 closed

Faillock sudo lockout

Done

apparmor-gopass-faillock/faillock-incident.adoc

ISE MnT diagnostics via sshpass

Done

ise-mnt-diagnostics.adoc — findings captured, restart pending

wpa_supplicant fix + MAC discovery

Done

wpa-supplicant-mac-discovery.adoc — INC-002 chain resolved

search-endpoint.sh operationalized

Done

ise-api-search-endpoint.adoc + dataconnect-overview.adoc

ers_all jq aggregation bug

Done

ise-common.sh — --slurpfile → jq -s

SSH 3560CX legacy crypto

Pending

ssh-config-fixes.adoc — 00-exceptions.conf patch ready

SSH modestus-p50 missing host

Pending

ssh-config-fixes.adoc — need IP for 80-d000-hosts.conf

gopass YAML for switch

Pending

/tmp/gopass-3560cx-01.yaml — template ready for gopass edit

apparmor-gopass-faillock

AppArmor Git/Gopass EACCES + Faillock Sudo Lockout

Two linked incidents during the ISE MnT diagnostics session. The AppArmor git deny on gopass stores caused repeated sudo failures, which triggered faillock and locked sudo entirely.

Faillock Sudo Lockout

Sudo stopped accepting password input — sudo: a password is required returned immediately with no prompt, or the prompt appeared but Enter had no effect.

Symptoms
  • sudo lssudo: a password is required (no prompt)

  • sudo ls → prompt appears, password typed, Enter pressed → rejected

  • Terminal stty showed -echoe -echok (unusual echo state)

Diagnosis
faillock --user evanusmodestus
evanusmodestus:
When                Type  Source                     Valid
2026-06-27 23:54:26 TTY   /dev/pts/24                    V
2026-06-27 23:42:50 TTY   /dev/pts/24                    I
2026-06-27 23:43:19 TTY   /dev/pts/24                    I

Failed sudo attempts triggered pam_faillock. Lockout threshold reached.

Fix
su
faillock --user evanusmodestus --reset
exit
sudo ls
su

Escalate via root password — faillock only blocks PAM-authenticated sudo, not su

faillock --reset

Clears the failure counter

sudo ls

Confirm sudo works again

Wazuh Evidence

Wazuh rule 80794 fired at 06:54:28 UTC — the unlock event:

Level 8 — Rule 80794
Audit: Passwd was used to unlock an account.
Agent: modestus-p16g
Time: 2026-06-28T06:54:28.276+0000
Wazuh Gap

Wazuh logged the unlock (rule 80794) but NOT the lockout itself. The failed sudo attempts that triggered faillock were not captured as alerts. Needs a custom decoder/rule for pam_faillock lockout events.

Lessons
  1. sudo: a password is required with no prompt = faillock lockout, not a password problem

  2. su bypasses faillock — use it to reset

  3. Check faillock --user <name> before assuming password is wrong

  4. Wazuh needs a custom rule for pam_faillock lockout events — currently blind to this

AppArmor Git Profile — Gopass Store Fix

INC-003: AppArmor’s /usr/bin/git profile has an explicit deny @{HOME}/.local/share/gopass/** r, that blocks git from reading .git/HEAD in gopass stores. gopass sync fails, git reports "not a git repository."

Root Cause — strace Proof
strace -e openat git -C ~/.local/share/gopass/stores/v2 status 2>&1 \
  | grep -i '.git\|EACCES'
openat(AT_FDCWD, "...gopass/stores/v2/.git/HEAD", O_RDONLY) = -1 EACCES (Permission denied)
The Offending Rule
# In /etc/apparmor.d/usr.bin.git:
  deny @\{HOME}/.local/share/gopass/** r,   ← BLOCKS git from reading .git/HEAD
  owner @\{HOME}/** rw,                     ← this ALLOW is overridden by the deny

AppArmor deny is absolute — overrides any allow regardless of order.

Fix — Before
sudo grep -n 'gopass' /etc/apparmor.d/usr.bin.git
Fix — Apply
sudo cp /etc/apparmor.d/usr.bin.git /etc/apparmor.d/usr.bin.git.bak.$(date +%F)

sudo sed -i 's|  deny @\{HOME}/\.local/share/gopass/\*\* r,|  # Gopass stores — git manages these as repos (sync, pull, push)\n  # Deny reading encrypted secrets but allow .git operations\n  deny @\{HOME}/.local/share/gopass/stores/*/*.gpg r,\n  owner @\{HOME}/.local/share/gopass/stores/** rwkl,|' /etc/apparmor.d/usr.bin.git

sudo apparmor_parser -R /etc/apparmor.d/usr.bin.git && \
  sudo apparmor_parser -a /etc/apparmor.d/usr.bin.git
Fix — After
sudo grep -n 'gopass' /etc/apparmor.d/usr.bin.git

strace -e openat git -C ~/.local/share/gopass/stores/v2 status 2>&1 | grep '.git/HEAD'

gopass sync

sshpass -p "$(gopass show v3/domains/d000/identity/ise/ise-01 | yq -r '.credentials.cli.password')" \
  ssh ise-01 'show application status ise' 2>&1 \
  | tee /tmp/ise-app-status-$(date +%F-%H%M).txt

.git/HEAD must return a file descriptor (e.g., = 3), not EACCES.

Pattern — Third AppArmor EACCES Incident
Incident Blocked Path Fix

INC-001 (curl)

~/.secrets/certs/d000/ise/ROOT-CA.crt

owner @{HOME}/.secrets/certs/** r, in curl profile

INC-003 (git)

~/.local/share/gopass/stores/*/.git/HEAD

Replace blanket deny with surgical deny .gpg + allow stores/*

(Future sshpass)

TBD

Check after git fix

The universal diagnostic: strace -e openat <command> 2>&1 | grep EACCES

ise-mnt-diagnostics

ISE MnT Diagnostics — sshpass Pipeline

First successful remote ISE CLI diagnostics session using the sshpass + gopass pipeline. The AppArmor git/gopass fix from earlier today unblocked gopass sync, which unblocked gopass show, which unblocked the sshpass pipeline.

Pipeline
PASS=$(gopass show v3/domains/d000/identity/ise/ise-01 | yq -r '.credentials.cli.password') (1)

sshpass -p "$PASS" ssh ise-01 \ (2)
  'screen-length 0 ; show application status ise' \ (3)
  2>&1 | tee /tmp/ise-app-status-$(date +%F-%H%M).txt (4)

unset PASS (5)
1 Extract CLI password from gopass YAML — tail -n +2 is implicit in yq since we pipe the full gopass output. The YAML starts after line 1 (the bare password line).
2 sshpass feeds the password non-interactively. ISE ADE-OS doesn’t support key-based auth — password is the only option.
3 screen-length 0 disables the ISE pager. This is NOT terminal length 0 (IOS syntax) — ISE ADE-OS uses different CLI commands. Without this, --More-- truncates output and the session hangs.
4 2>&1 merges stderr (SSH banners, warnings) into stdout. tee writes to file while displaying — timestamped for the record.
5 Credential hygiene — clear the password from the shell environment immediately after use. Prevents exposure via /proc/$$/environ or env.
Findings from /tmp/ise-diagnostics-2026-06-28-0008.txt
Log Finding Severity

ise-psc.log

Normal — SSH login accepted, MnT alarm tasks running, portal ports active

OK

collector.log

GC Event queue full — PS MarkSweep major GC every ~6 min, duration 388-545ms

WARNING — JVM memory pressure

prrt-server.log

Crypto.Certificate::getKeyUsage - There is no Key Usage every 60s

WARNING — cert missing Key Usage extension

prrt-server.log

onAccountingInterim: Audit session was not found for MAC A0:59:50:C9:92:EE at 10.50.40.144

WARNING — session cache miss, related to MnT collection gap

prrt-management.log

Single AD diagnostic entry — quiet

OK

mnt-la-elasticsearch

discovery.seed_hosts=[localhost, 127.0.0.1] misconfigured for single-node cluster — fires every 30s

COSMETIC — noisy but harmless

show disks

/opt 25% (58G/253G), / 30% (8.4G/31G)

OK

show memory

Total 16GB, used 12GB, available 2GB, swap 1.1GB/9.2GB

WARNING — ISE is swapping, memory-constrained

Conclusion

All ISE services report running. Memory pressure (12GB/16GB, swapping) combined with GC queue overflow suggests MnT collection may be failing intermittently under load. The session cache miss for A0:59:50:C9:92:EE confirms ISE is dropping sessions. Next step: restart ISE application to clear JVM memory pressure, then verify MnT collection resumes.

wpa-supplicant-mac-discovery

wpa_supplicant Fix Chain + MAC Discovery

The root cause of 20+ days of missing wired 802.1X sessions: wpa_supplicant-wired@enp134s0 was not running. Fixing this revealed the correct wired MAC address for the P16g.

Fix Applied
sudo systemctl enable --now wpa_supplicant-wired@enp134s0 (1)
1 enable --now does two things: creates the symlink for boot persistence AND starts the service immediately. Without --now, enable alone only affects next boot — the supplicant stays dead until reboot. This single missing --now was the root cause of 20 days of missing wired sessions.

The config also had eap=TEAP — unsupported by the compiled wpa_supplicant binary on Arch. Changed to eap=TLS.

MAC Address Discovery via search-endpoint.sh

Used the new search-endpoint.sh DataConnect tool to confirm the P16g’s identity in ISE:

bash $(find data -name 'search-endpoint.sh' -path '*/dataconnect/*') p16g (1)
1 $(find …​) locates the script by name regardless of working directory — portable across machines. The argument p16g searches radius_authentications.username (EAP-TLS certificate CN), endpoints_data.hostname, and calling_station_id (MAC) via LIKE '%p16g%'.
Table 1. Results
MAC Interface NAS Port Last Auth

E0:D5:5D:6C:E1:66

wlan0 (wireless)

capwap_90000002 via WLC 10.50.1.40

2026-06-08

A8:2B:DD:8F:23:E6

enp134s0 (wired)

GigabitEthernet1/0/2 via 3560CX 10.50.1.10

2026-06-07

Confirmed Wired MAC
ip link show enp134s0 | awk '/ether/{print $2}' (1)
# a8:2b:dd:8f:23:e6
1 awk '/ether/' matches the line containing the MAC address. {print $2} extracts the second field — the MAC itself. Faster than grep -o with a regex pattern, and the output is clean (no surrounding text).

This is NOT 14:F6:D8:7B:31:80 (previously assumed wired MAC in SSH config and Antora attributes). The 14:F6:D8:7B:31:80 MAC is the Thunderbolt/USB ethernet adapter — only used when docked. A8:2B:DD:8F:23:E6 is the built-in Intel NIC.

Impact
Item Action

14:F6:D8:7B:31:80 attribute

Needs updating: 14:F6:D8:7B:31:80A8:2B:DD:8F:23:E6 (or document both)

ISE endpoint

Both MACs exist in ISE endpoint table — both authenticate via EAP-TLS with CN modestus-p16g.inside.domusdigitalis.dev

gopass ISE entry

nads section references the dock MAC — update or add second entry

SSH config

modestus-p50 still unresolved — no DNS entry, no SSH config host entry

ise-api-search-endpoint

ISE API — search-endpoint.sh Operationalized

Built and validated search-endpoint.sh — a DataConnect script that searches for any endpoint by hostname, EAP-TLS certificate CN, or MAC address pattern. Searches radius_authentications joined with endpoints_data.

Why ERS Can’t Do This

ERS endpoint names are MAC addresses. Searching by hostname (p50, vocera, timeclock) returns zero results. DataConnect stores the EAP-TLS certificate CN in radius_authentications.username and DHCP hostname in endpoints_data.hostname — both searchable.

Usage
dsource d000 dev/network/ise (1)
bash $(find data -name 'search-endpoint.sh' -path '*/dataconnect/*') p16g (2)
bash $(find data -name 'search-endpoint.sh' -path '*/dataconnect/*') p50 (3)
bash $(find data -name 'search-endpoint.sh' -path '*/dataconnect/*') 14:F6:D8 (4)

dsource d001 dev/network/ise (5)
bash $(find data -name 'search-endpoint.sh' -path '*/dataconnect/*') vocera (6)
1 dsource loads ISE API credentials into the shell environment — ISE_PAN_FQDN, ISE_API_USER, ISE_API_PASS, ISE_CA_CERT, ISE_DATACONNECT_*. The d000 argument selects the home lab; d001 selects production.
2 Search by hostname substring — matches radius_authentications.username where EAP-TLS stores the certificate CN (e.g., modestus-p16g.inside.domusdigitalis.dev).
3 Same pattern, different host — found P50 with MAC 14:F6:D8:7B:31:80, last auth 2026-06-13 via wireless.
4 Partial MAC search — LIKE '%14:F6:D8%' matches any endpoint with this OUI prefix.
5 Switching to production ISE — the exact same script, different credentials. dc_query connects to the d001 DataConnect instance.
6 At work: "Hey Evan, are the Vocera phones authenticating?" — one command, instant answer with MAC, IP, profile, NAS port, auth protocol, pass/fail, and timestamp.
ers_all Fix

ers_all() in ise-common.sh had a jq bug — --slurpfile loaded files into variable $pages but the expression referenced .pages[] (a field path). All paginated ERS calls returned invalid JSON. Fixed to jq -s with stdin slurp. Added empty-result guard for --argjson total when API returns non-JSON.

Files Created/Modified
  • scripts/dataconnect/search-endpoint.sh — new

  • scripts/dataconnect/queries/search-endpoint.sql — new

  • partials/dataconnect-overview.adoc — wired in with collapsible, validated output, usage examples

  • scripts/lib/ise-common.shers_all fix + empty-result guard

Gopass Switch Entry

Created YAML template at /tmp/gopass-3560cx-01.yaml for the lab switch — credentials for SSH, enable, console, API, SNMP, RADIUS, and TACACS with _use: keys per gopass YAML standard from the 2026-06-24 audit.

Path: v3/domains/d000/network/switch/3560cx-01

ise-health-check-script

ISE health-check.sh — Post-Restart Validation

Built health-check.sh to validate all 5 ISE API surfaces in a single command. Environment-agnostic via dsource — works against d000 and d001.

What It Checks
API Check Metric

ERS (9060)

/endpoint?size=1

Total endpoint count

MnT (443)

/Session/ActiveCount

Active session count (XML → xq)

OpenAPI (443)

/api/v1/policy/network-access/policy-set

Policy set count

DataConnect (2484)

radius_authentications COUNT

Auths in last hour

pxGrid (8910)

control/AccountActivate

Account state (ENABLED/DISABLED)

Plus system info (hostname, FQDN, version, patch, roles, services) and DataConnect summary stats (total auths, 24h auths, 24h failures, unique MACs).

Iteration Log
Issue Fix

local outside function

Removed local from main body — bash requires local inside functions only

MnT empty response

Added guard: if response is empty, print "MnT not ready" instead of crashing xq

OpenAPI missing /api/v1/

openapi() doesn’t prepend the path prefix — caller must provide full path

Gopass path in public script

Removed CLI diagnostics block — credential paths don’t belong in shareable scripts

Version/patch showing "—"

ERS /node doesn’t return version fields — added OpenAPI /api/v1/deployment/node fallback

sshpass hang

Removed CLI diagnostics entirely — sshpass blocks on GPG passphrase or SSH timeout

Usage
dsource d000 dev/network/ise (1)
bash data/d000/infra/ise-api-reference/scripts/health-check.sh (2)
1 Loads all ISE credentials into environment — ERS, MnT, OpenAPI, DataConnect, and pxGrid. The health check script sources ise-common.sh which validates these variables are set before any API call.
2 Checks all 5 surfaces sequentially. Each check uses check_api() — a function that captures the command output, prints on success or ✗ FAILED with the error message. Output auto-saved to /tmp/ise-health-check-YYYY-MM-DD-HHMM.txt.

Documented in partials/cross-api-patterns.adoc with collapsible source and validated output sample.

ssh-config-fixes

SSH config.d Fixes

Two SSH connectivity issues discovered during switch diagnostics.

Issue 1 — 3560CX Legacy Crypto

ssh 3560cx-01 fails:

Unable to negotiate with 10.50.1.10 port 22: no matching host key type found. Their offer: ssh-rsa

Root cause: 30-cisco-defaults.conf matches 3560cx-* with HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256 — but the 3560CX runs classic IOS (not IOS-XE) and only offers ssh-rsa. Same for KEX — only diffie-hellman-group14-sha1.

Fix: Add legacy exception in 00-exceptions.conf (loads before 30-cisco-defaults.conf — SSH uses first match):

Host 3560cx-01 (1)
    User admin
    PubkeyAuthentication no (2)
    PasswordAuthentication yes
    KbdInteractiveAuthentication yes
    PreferredAuthentications keyboard-interactive,password
    KexAlgorithms +diffie-hellman-group14-sha1 (3)
    HostKeyAlgorithms +ssh-rsa (4)
    PubkeyAcceptedAlgorithms +ssh-rsa
    Ciphers aes256-ctr,aes128-ctr (5)
    MACs hmac-sha2-256,hmac-sha1
    ControlMaster no (6)
    ConnectTimeout 10
1 Exact hostname match in 00-exceptions.conf — wins over the wildcard 3560cx-* in 30-cisco-defaults.conf because SSH uses first match (lexicographic file order).
2 Classic IOS has no SSH key auth — disable pubkey to avoid timeout delays probing unsupported algorithms.
3 + prefix appends to (not replaces) the system default KEX list. diffie-hellman-group14-sha1 is the only KEX the 3560CX supports — without it, negotiation fails before authentication.
4 +ssh-rsa appends the legacy host key algorithm. Modern OpenSSH disabled ssh-rsa by default (SHA-1 signatures). The 3560CX offers nothing else — this is the actual error: "no matching host key type found."
5 AES-CTR only — the 3560CX doesn’t support GCM ciphers. aes256-ctr is the strongest option available.
6 ControlMaster no prevents SSH multiplexing — IOS doesn’t handle shared connections correctly. Each session must be independent.

Status: Pending — user to apply and re-encrypt.

Issue 2 — modestus-p50 Missing Host Entry

ssh modestus-p50 fails:

ssh: Could not resolve hostname modestus-p50: Name or service not known

Root cause: No Host modestus-p50 entry in 80-d000-hosts.conf. The hostname exists in DNS but SSH config doesn’t have a mapping.

Fix: Add to 80-d000-hosts.conf:

Host modestus-p50
    HostName <IP — resolve via dig or ISE DataConnect search>

Used search-endpoint.sh p50 to find the P50 in ISE: MAC 14:F6:D8:7B:31:80, CN p50.inside.domusdigitalis.dev, last auth via wireless on 2026-06-13.

Status: Pending — need IP address (DHCP or static reservation).

Post-fix
age -e -R ~/.age/recipients/self.txt \ (1)
  -o ~/.ssh/config.d/80-d000-hosts.conf.age \ (2)
  ~/.ssh/config.d/80-d000-hosts.conf (3)
1 -R specifies the recipients file — the public key(s) that can decrypt. Using a file avoids embedding keys on the command line.
2 -o sets the output path — the encrypted .age file that gets tracked in git. The plaintext source is gitignored.
3 The plaintext input file. After encrypting, this stays on disk (protected by .gitignore) for stow to symlink into ~/.ssh/config.d/.

cpu-temp-runaway-less

CPU Temperature Spike — Runaway less Process

Symptoms
  • Package temp 93°C (crit 105°C)

  • Core 28 at 94°C, Core 44 at 84°C — asymmetric spike

  • Load average 2.4 on 24 cores — not high, but sustained

  • No obvious userspace CPU consumer in ps aux

Diagnostic
sensors | head -30 (1)
top -bn1 | head -15 (2)
1 sensors reads coretemp-isa-0000 — the kernel’s hardware monitoring interface. Each core reports independently. Package temp is the maximum across all cores — the thermal throttle trigger.
2 top -bn1 — batch mode (-b), single iteration (-n1). Without -b, top takes over the terminal. The output is sorted by CPU% by default — the first non-header line is the culprit.
top output (2026-06-28 10:23)
    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
3383353 evanusm+  20   0    7180   4952   4568 R 100.0   0.0 201:17.31 less
1243951 evanusm+  20   0 3171916   1.7g 100048 S  28.6   2.7  48:54.30 claude
Root Cause

PID 3383353: less -R -F -K reading from a disconnected pipe. The source process exited but less kept spinning at 100% on a single core for 201 minutes (3.3 hours). The -F flag (quit if one screen) doesn’t trigger when stdin is a pipe that’s been closed — less enters a busy-wait loop.

cat /proc/3383353/cmdline | tr '\0' ' ' (1)
# less -R -F -K

ls -l /proc/3383353/fd/0 (2)
# pipe:[5994563]  ← orphaned pipe, source gone
1 /proc/<pid>/cmdline stores the full command line with NUL (\0) delimiters between arguments — not spaces. tr '\0' ' ' makes it human-readable. This is how you recover the exact invocation of any running process.
2 /proc/<pid>/fd/0 is stdin. The symlink target pipe:[5994563] is a kernel pipe inode — the number is the inode ID. When the writing process exits, the pipe becomes orphaned but less doesn’t detect EOF and busy-waits instead.
Fix
kill 3383353
Prevention

This pattern recurs with pagers attached to pipes. The -F + -K flags on less are common in $PAGER / $MANPAGER / git config. When the pipe source exits uncleanly (Ctrl+C, broken SSH, killed process), less can spin indefinitely.

Options:

  • Set LESS="-R -F -X -K --quit-if-one-screen" — the --quit-if-one-screen variant handles edge cases better

  • Periodic check: ps aux | awk '$3 > 50 && /less/{print}' — catches runaway pagers

  • tmux/terminal alert on sustained high CPU per process

cli-search-proficiency

CLI Search Proficiency — Querying Your Own System

The entire documentation system is searchable text. grep -rn 'bash test' docs/modules returned nothing because the prose "bash test" doesn’t appear — the actual syntax is [[ ]]. The gap isn’t tooling, it’s knowing how to ask the question in each tool’s language.

The entire documentation system is searchable text — AsciiDoc, YAML, shell scripts, SQL, config files. Every tool you need is already on the machine. The gap isn’t tooling, it’s fluency — knowing which tool to reach for and how to compose them.

grep — Pattern Search (the foundation)
grep -rn '\[\[' docs/modules/ROOT/partials/ (1)
grep -rn '\[\[.*\]\]' docs/modules/ROOT/partials/ --include='*.adoc' (2)
grep -rnP '(?<=\[\[).*?(?=\]\])' docs/modules/ROOT/ --include='*.adoc' (3)
grep -rl 'strace' docs/ data/ (4)
grep -rnC2 'EACCES' docs/modules/ROOT/partials/standards/ (5)
grep -rn 'source,bash' docs/ --include='*.adoc' | wc -l (6)
grep -rnv '^//' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28.adoc (7)
1 -rn — recursive + line numbers. The bare minimum for any search. Find bash test [[ ]] syntax across all partials.
2 --include='*.adoc' — restrict to AsciiDoc files. Without this, grep hits .json, .sh, .yaml — noise when you want documentation.
3 -P — PCRE (Perl-compatible regex). Lookahead (?⇐\[\[) and lookbehind (?=\]\]) extract content inside [[ ]] without the brackets themselves. This is the grep that couldn’t find your bash tests — because bash test is prose, the actual syntax is [[ ]].
4 -rl — recursive, files-only. "Which files mention strace?" — paths only, no line content. Fastest way to scope a search.
5 -C2 — 2 lines of context above and below each match. When you need to see the section heading that contains the match.
6 Pipe to wc -l — counting, not reading. "How many code blocks do I have?" Answer: a number, not a wall of text.
7 -v — invert match. Lines that are NOT comments. See the actual content of an assembler without the // headers.
find — File Discovery (the navigator)
find docs/ -name '*.adoc' -newer docs/antora.yml (1)
find data/ -name '*.sh' -executable (2)
find docs/modules/ROOT/partials/worklog/daily-notes/ -type d -name '2026-06-*' (3)
find . -name '*.adoc' -path '*/standards/*' (4)
find data/ -name '*.sql' -exec head -2 {} + (5)
find docs/ -name '*.adoc' -size +50k (6)
find . -name '*.adoc' -newer /tmp/ise-health-check-2026-06-28-1017.txt (7)
1 -newer — files modified after a reference file. "What changed since I last touched antora.yml?" Time-based triage without git.
2 -executable — only scripts with the execute bit. Filters out .sh files that are source includes (not runnable).
3 -type d — directories only. "Which days have daily notes this month?" One line, instant answer.
4 -path '/standards/' — glob on the full path. More precise than -name when files share names across directories.
5 -exec head -2 {} + — run head on every match, batched. Read the first two lines of every SQL file — the comment header tells you what each query does without opening them.
6 -size +50k — files over 50KB. Find monolithic partials that violate the "split when large" principle.
7 -newer with a /tmp timestamp file — "what .adoc files did I touch since the health check?" Uses any file as a time reference.
awk — Field Extraction (the surgeon)
awk '/^=====/{print FILENAME": "$0}' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28/*/*.adoc (1)
awk '/^\| Task/,/^\|===/' data/d000/infra/ise-api-reference/partials/cross-api-patterns.adoc | awk '/^\|/{gsub(/^\| */,""); print}' (2)
awk '/^\[source,bash\]/{p=1;next} p&&/^----/{if(++d==1)next; p=0;d=0;next} p&&d' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28/ise-mnt-diagnostics/sshpass-pipeline.adoc (3)
awk '/^include::/{gsub(/include::|partial\$|\[\]/,""); print}' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28.adoc (4)
awk 'FNR==1{print "--- "FILENAME" ---"} /TODO|FIXME|HACK/{print FNR": "$0}' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28/*/*.adoc (5)
1 Pattern match on ===== (h5 headings) — extract every section title from today’s sub-partials with the filename prefix. Instant table of contents.
2 Two-stage awk pipeline. First awk uses range pattern /^\| Task/,/^\|===/ to extract the table between the header row and the closing delimiter. Second awk strips the leading | ` from each cell. AsciiDoc multi-line tables put each cell on its own `| value line — -F'|' doesn’t work because there’s only one | per line.
3 State machine: p=1 arms on [source,bash], d counts ---- delimiters (first opens, second closes), content prints only when p&&d are both true. Cleaner than range patterns which leak the delimiter lines.
4 gsub — global substitution. Strip include::, partial$, and [] from include directives to get bare file paths. Shows the dependency tree of an assembler.
5 FNR==1 — first line of each file (multi-file aware). Print a header per file, then any TODO/FIXME/HACK lines with line numbers. Code audit in one pass.
sed — In-Place Transformation (the editor)
sed -n '/^====/,/^====/p' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28/cpu-temp-runaway-less/investigation.adoc (1)
sed -n '10,25p' data/d000/infra/ise-api-reference/scripts/lib/ise-common.sh (2)
sed '/^\/\//d' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28/ise-mnt-diagnostics.adoc (3)
sed -n 's/.*include::\(.*\)\[\].*/\1/p' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28.adoc (4)
1 Range address /start/,/end/ with -n and p — print only the first section (between two ==== headings). Non-destructive extraction.
2 Line range 10,25p — print lines 10-25. When you know the line numbers from a grep -n result and want context. Faster than opening in an editor.
3 /^\/\//d — delete comment lines. View the rendered content of any AsciiDoc file without the // metadata headers.
4 Capture group \(…​\) — extract the path from every include:: directive. Same as the awk version but using sed’s substitution with backreferences.
Composition — Multi-Tool Pipelines
grep -rl 'AppArmor' docs/ | xargs grep -l 'EACCES' (1)
find data/ -name '*.sh' | xargs head -5 | grep -B1 'Author' (2)
grep -rn 'dc_query' data/d000/infra/ise-api-reference/scripts/ | awk -F: '{print $1}' | sort -u (3)
find docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-28/ -name '*.adoc' -exec grep -l 'callout\|<1>' {} + (4)
grep -rn '\[source,' docs/ --include='*.adoc' | awk -F, '{print $2}' | tr -d ']' | sort | uniq -c | sort -rn (5)
1 Two-stage grep — first find files mentioning AppArmor, then narrow to those that also mention EACCES. Intersection search.
2 find | xargs head | grep -B1 — three-stage pipeline. Find all scripts, read their first 5 lines, filter for Author metadata with one line of context above (the filename separator from head).
3 awk -F: '{print $1}' | sort -u — extract unique filenames from grep output. "Which scripts use dc_query?" — deduplicated file list.
4 find -exec grep -l — find files containing callout markers. Verify which sub-partials have callouts and which still need them.
5 Language census — count every [source,<lang>] block across all docs, sort by frequency. "How much bash vs SQL vs yaml do I have?" One pipeline.
The Missing Piece — Terminal-Native Idea Capture

You have 19 repos, 48 projects, 19,479 bash code blocks, and a four-layer documentation standard. You have discoveries (STD-019), daily triage tables, project inboxes, cogitatio files, and reMarkable PDF exports. What you don’t have is a zero-friction command that captures a thought the instant it forms and routes it to the right place later.

The idea isn’t silly. It’s the capstone of everything you’ve built.

What Already Exists
System Where It Lives Gap

Discoveries (STD-019)

partials/discoveries/<cat>/<topic>.adoc

Requires knowing the category at capture time — too much friction for raw ideas

Daily triage table

partials/worklog/daily-notes/YYYY-MM-DD.adoc

Right paradigm ("write here first, distribute later") but buried inside the worklog shell

Project inbox

data/<tier>/projects/<slug>/partials/inbox.adoc

Per-project — useless when the idea doesn’t have a project yet

Cogitatio

data/d000/personal/cogitatio/ideas-*.adoc

Domain-organized personal capture — closest to what you want, but age-encrypted and not linked to the CLI

reMarkable ideas

make rm-ideas → PDF on tablet

Analog capture — great for offline, but not searchable or linkable

What You Actually Want

A command that does this:

idea "AppArmor profiles should be version-controlled in git" (1)
idea -p ise-api "add certificate expiry check to health-check.sh" (2)
idea -t education "read chapter 7 of K&R before Thursday" (3)
idea --review (4)
idea --search "apparmor" (5)
1 Bare idea — no category, no project, no ceremony. Timestamped, appended to today’s inbox, searchable immediately. The thought takes 3 seconds to capture instead of dying in your frontal lobe.
2 -p routes to a project’s inbox partial. If the project exists, append. If not, create the inbox.
3 -t tags with a category for later triage — education, infrastructure, work, personal, project-idea.
4 --review opens today’s captured ideas for triage — move to discoveries, link to projects, or archive to graveyard.
5 --search runs grep -rn across all idea files. Your entire ideation history, searchable from the terminal.
Architecture
~/.local/bin/idea                          ← shell function or script
  │
  ├── Default: append to today's idea file
  │   docs/modules/ROOT/partials/worklog/daily-notes/YYYY-MM-DD/ideas/raw.adoc
  │
  ├── -p <project>: append to project inbox
  │   data/<tier>/projects/<slug>/partials/inbox.adoc
  │
  ├── -t <tag>: append with category tag
  │   Same file, but prefixed with [tag] for triage filtering
  │
  ├── --review: open today's ideas in $EDITOR
  │   nvim $(find docs/modules/ROOT/partials/worklog/daily-notes/$(date +%F) -name 'raw.adoc')
  │
  └── --search <term>: grep all idea files
      grep -rn <term> docs/modules/ROOT/partials/worklog/daily-notes/*/ideas/
Storage Format

Each idea is one entry in today’s ideas/raw.adoc sub-partial:

===== 2026-06-28 10:45 — AppArmor profiles should be version-controlled
[tags: infrastructure, apparmor]

AppArmor profiles should be version-controlled in git. Currently editing
/etc/apparmor.d/ directly with sudo sed — no history, no rollback, no
diff. The curl and git profile fixes from this week would have been
safer with a git-managed workflow.

**Status:** raw +
**Disposition:** _pending triage_

---
Lifecycle — From Thought to Artifact
Captured (raw)                             ← idea "thought"
  │
  ├── Triage (daily/weekly review)         ← idea --review
  │   │
  │   ├── → Graveyard                      ← not worth pursuing, archived with reason
  │   │     docs/modules/ROOT/partials/worklog/ideas/graveyard.adoc
  │   │
  │   ├── → Discovery (STD-019)            ← micro-learning, append to discoveries/<cat>/
  │   │     Graduation: 3+ references → Codex
  │   │
  │   ├── → Existing Project               ← link to project inbox or backlog
  │   │     data/<tier>/projects/<slug>/partials/inbox.adoc
  │   │
  │   └── → New Project                    ← idea becomes PRJ-YYYY-MM-<slug>
  │         data/project-master.yaml + full project scaffold
  │
  └── Search (anytime)                     ← idea --search "term"
      grep across all raw + triaged idea files
Triage Categories
Disposition Where It Goes Trigger

Graveyard

partials/worklog/ideas/graveyard.adoc

Not actionable, already solved, or low value — archived with reason so you don’t re-think it

Discovery

partials/discoveries/<cat>/<topic>.adoc (STD-019)

A technique, tool, or insight worth remembering — graduates to Codex at 3+ references

Project backlog

data/<tier>/projects/<slug>/partials/inbox.adoc

Actionable and belongs to an existing project — becomes a task or phase

New project

data/project-master.yaml + full scaffold

Big enough to be its own project — gets PRJ ID, metadata, summary, phases

Concern partial

partials/worklog/daily-notes/YYYY-MM-DD/<concern>/

Worth documenting today — becomes a daily note concern with sub-partials

Standard update

partials/standards/<domain>/<standard>.adoc

Policy-level insight — update an existing standard or propose a new one

Why This Matters

Every system you’ve built — ISE API reference, systems-debugging curriculum, AppArmor troubleshooting thoroughfares, the four-layer worklog architecture — started as a thought. The ones that made it into artifacts are the ones that happened to occur while you were already at the keyboard with a file open. The ones that died are the ones that hit while you were in a meeting, on a call, walking to the kitchen, or debugging something else.

The idea command closes that gap. Three seconds from thought to searchable text. Zero friction. The triage happens later, when you have context and calm — not in the moment of inspiration when you’re already deep in something else.

This is the exordium of every future project.

c-programming-language-study

K&R C Programming Language Study Companion

Built complete study companion for The C Programming Language (Kernighan & Ritchie, 2nd edition). All 8 chapters with inline callouts, terminal-drop blocks, concept admonitions, pre-rendered diagrams, and exercise trackers.

What was built
  • 8 chapter partials — ch01 through ch08, every section documented with inline code + callouts

  • 34 C source filesscripts/ch01/ through scripts/ch08/, all compilable with {compiler} {compiler-flags}

  • 5 pre-rendered diagrams — D2 and Graphviz in images/ch01/ (startup sequence, state machine, array layout, call-by-value stack, string memory)

  • Education standardpartials/education-std.adoc behind ifdef::show-std[]

  • 9 custom admonition roles[TIP.terminal], [IMPORTANT.concept], [NOTE.strace], [NOTE.reference], [NOTE.compile], [WARNING.debug], [TIP.exercise], [NOTE.mission], [NOTE.progress]

  • STD-025 created — universal AsciiDoc authoring standard with diagrams (D2 taxonomy, Mermaid flow, Graphviz architecture)

How to open
# Build
cd ~/atelier/_bibliotheca/domus-captures && make build-data PROJ=c-programming

# View
firefox data/d000/education/c-programming-language/output/c-programming-language.html &

# Build with education standard visible
make build-data PROJ=c-programming ENV=show-std
References
  • Standard: data/d000/education/EDUCATION-ASCIIDOC-STD.adoc

  • STD-025: docs/modules/ROOT/pages/standards/STD-025-asciidoc-authoring.adoc

  • CSS roles: domus-asciidoc-build/docinfo/css-light-cyan/admonitions.css

  • Project: data/d000/education/c-programming-language/

domus-nvim-overhaul

domus-nvim — C Support & AsciiDoc Snippets

Continued overhaul from yesterday. Focus today: C/systems programming support, rainbow-delimiters crash fix, AsciiDoc snippet expansion.

Changes
  • C/systems programmingftplugin/c.lua with <leader>cr (compile+run), <leader>cm (quickfix), K (man pages for syscalls). clangd handler with --clang-tidy, --header-insertion=iwyu. DAP config for C with cwd/ default. 15 C snippets (main, syscall, fork, mmap, socket, signal, pthread, etc.)

  • rainbow-delimiters fix — disabled for vim filetype via condition function. Local strategy crashed on nil treesitter nodes in regex-heavy syntax files.

  • AsciiDoc snippets — 40+ new snippets: titled block admonitions (all 5 types), choice-based admon, complex admonitions, tabs (@asciidoctor/tabs), anchors, icons, tables (5 variants), lists (nested, steps). Block admonition keymaps: <leader>aBn/aBt/aBw/aBc/aBi (normal + visual wrap).

  • Harpoon keymap — moved from <leader>a to <leader>ha to resolve collision with AsciiDoc prefix.

How to open
NVIM_APPNAME=domus-nvim nvim
# or alias: v
Commits

d624b5c, dc5e84a, 6cd602e, bf35771, 61d14aa

References
  • Repo: ~/atelier/_projects/personal/domus-nvim

  • Antora docs: make docs-serve (port 8000)

  • Config dir: ~/.config/domus-nvim (symlink)

apparmor-mmdc-sandbox

AppArmor Blocking mmdc (Mermaid CLI) — Chromium Sandbox Denied

Symptom

Building C programming study companion with make build-data PROJ=c-programming — Mermaid diagram blocks fail:

asciidoctor: ERROR: partials/ch01-tutorial-intro.adoc: line 58: Failed to generate image: mmdc failed:

Direct mmdc test confirms:

echo 'sequenceDiagram
    A->>B: hello' | mmdc -i - -o /tmp/test.png (1)
1 Fails with No usable sandbox! — Puppeteer cannot launch headless Chromium.
Error
Error: Failed to launch the browser process!
[FATAL:zygote_host_impl_linux.cc(128)] No usable sandbox!
If you are running on Ubuntu 23.10+ or another Linux distro that has disabled
unprivileged user namespaces with AppArmor, see
https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
Root Cause

AppArmor restricts unprivileged_userns_clone. Chromium’s sandbox uses clone() with CLONE_NEWUSER to create a user namespace — AppArmor denies this.

This is the same mechanism that protects against container escape exploits. Chromium needs it for process isolation. AppArmor blocks it because unprivileged user namespaces are a known privilege escalation vector (CVE-2023-2640, CVE-2023-32629).

# Verify the restriction
cat /proc/sys/kernel/unprivileged_userns_clone (1)
sysctl kernel.unprivileged_userns_clone        (2)
aa-status | grep chromium                       (3)
1 0 = disabled (AppArmor default on Arch with hardened kernel)
2 Same check via sysctl
3 Check if Chromium has an AppArmor profile loaded
Pattern: Three AppArmor Incidents in 4 Days
Date What broke Root cause Fix

2026-06-25

curl --cacert ISE API calls fail with error 77

deny @{HOME}/.secrets/** r, in curl profile blocks CA cert reads

Narrowed deny scope — allow .secrets/gopass/stores//ROOT-CA

2026-06-27

gopass cannot sync — git exec blocked

gopass profile has no git in allowed execution chain

Pending — need to add /usr/bin/git Px, to gopass profile

2026-06-28

mmdc (Mermaid CLI) cannot render diagrams

AppArmor blocks clone(CLONE_NEWUSER) — Chromium sandbox needs unprivileged user namespaces

Replaced Mermaid with D2 in diagrams (D2 doesn’t need a browser)

What this pattern teaches
AppArmor is a deny-by-default system

Every program that runs under an AppArmor profile can ONLY do what the profile explicitly allows. This includes:

  • File access — which paths can be read, written, created

  • Program execution — which binaries can be exec’d (and in which mode: `Px, Ux, ix)

  • Capabilities — which kernel capabilities are available (CAP_NET_ADMIN, etc.)

  • Network — which socket types and ports are permitted

  • Namespace operationsclone() with CLONE_NEWUSER, CLONE_NEWNS, etc.

The three incidents show three different facets of the same constraint:

  1. File deny (curl) — profile explicitly denies a path the program needs

  2. Exec deny (gopass) — profile doesn’t list a binary the program needs to spawn

  3. Namespace deny (mmdc/Chromium) — kernel-level restriction on sandbox operations

Each one requires a different diagnostic approach:

  • File deny → strace -e openat shows EACCES

  • Exec deny → aa-status shows allowed execution chain, dmesg shows DENIED

  • Namespace deny → clone() failure in strace, kernel log shows DENIED operation="userns_create"

Workaround Applied

Replaced Mermaid ([mermaid,format=png]) with D2 ([d2,format=png]) for all diagrams in the C programming study companion. D2 renders natively without a browser — no Puppeteer, no Chromium, no sandbox.

Diagnostic Commands for Future Reference
# Check if unprivileged user namespaces are enabled
sysctl kernel.unprivileged_userns_clone (1)

# Check AppArmor status for a specific binary
aa-status | grep -A5 'mmdc\|chromium\|puppeteer' (2)

# Watch for AppArmor denials in real time
sudo dmesg -w | grep -i 'DENIED\|apparmor' (3)

# Check audit log for recent denials
sudo ausearch -m AVC -ts recent | grep -i 'DENIED' (4)

# Trace the failing process
strace -f -e clone,clone3 mmdc -i input.mmd -o output.png 2>&1 | grep -i 'EPERM\|EACCES' (5)
1 Returns 0 if disabled (default on hardened Arch). 1 if enabled.
2 Shows which AppArmor profiles are loaded for diagram tools.
3 Real-time kernel log — DENIED messages appear here for implicit denials.
4 Audit framework — more structured than dmesg. Requires auditd running.
5 Traces the clone() syscall that creates the user namespace. EPERM confirms AppArmor denial.
Options NOT Taken (and why)
Option How Why not

Enable unprivileged userns

sysctl -w kernel.unprivileged_userns_clone=1

Opens privilege escalation vector. Defeats the purpose of AppArmor hardening.

Run mmdc with --no-sandbox

PUPPETEER_CHROMIUM_ARGS="--no-sandbox" mmdc …​

Disables Chromium’s process isolation. Renders untrusted Mermaid input in an unsandboxed browser.

Create AppArmor profile for Chromium allowing userns

aa-genprof chromium with userns capability

Too broad — would apply to all Chromium instances, not just mmdc.

Use Kroki server instead

Offload rendering to kroki.io or local Docker

Works for Antora builds (already configured). Not available for standalone build-adoc without Docker.

TODO
  • Investigate Kroki as a local diagram server for standalone builds (Docker required)

  • Consider d2 as the primary diagram language — no browser dependency, cleaner syntax, native binary

  • Document this pattern in the AppArmor codex page

parental-controls-p50

Parental Controls — gabriel-ThinkPad-P50

Remote administration of son’s workstation via SSH. DNS-level blocking via /etc/hosts + chattr +i immutable flag. Network visibility via lsof. Firefox forensics via places.sqlite (browsing history) and recovery.jsonlz4 (active tabs).

Session — 2026-06-28

Remote administration of gabriel-ThinkPad-P50 (10.50.10.107) via SSH from modestus-p16g.

SSH Config Fix
echo -e '\nHost modestus-p50\n    HostName 10.50.10.170' >> ~/.ssh/config.d/80-d000-hosts.conf (1)
sed -i 's/10\.50\.10\.170/10.50.10.107/' ~/.ssh/config.d/80-d000-hosts.conf (2)
grep '10.50.10.107' ~/.ssh/config.d/80-d000-hosts.conf (3)
ssh modestus-p50 (4)
1 Initial entry — wrong last octet (170 instead of 107)
2 sed -i in-place fix — verify-before/change/verify-after from memory
3 Verify-after — confirms the substitution took
4 SSH in — host entry resolves, password auth to the P50
Hosts File — DNS-Level Blocking
cat /etc/hosts (1)
sudo chattr +i /etc/hosts (2)
lsattr /etc/hosts (3)
# ----i---------e------- /etc/hosts
1 Verify blocks are active — 127.0.0.1 entries for YouTube, TikTok, Twitch, Reddit, streaming services. Lincoln Learning domains left unblocked (educational — allowed).
2 chattr +i — set immutable attribute. Even root cannot modify, delete, or rename the file. Requires chattr -i to unlock. Prevents the child from editing /etc/hosts even with sudo access.
3 lsattr — verify the i (immutable) flag is set. The e flag is extent-based (filesystem default — ignore it).
Network Visibility — lsof
sudo lsof -i -n -P | grep -i 'firefox\|chrome\|ESTABLISHED' | awk '{print $1, $3, $9}' | sort -u (1)
1 -i network connections only. -n no DNS resolution (fast). -P no port name resolution (show numbers). Piped through awk to extract process name ($1), user ($3), and connection ($9). sort -u deduplicates.
Output — 2026-06-28 session
firefox gabriel 10.50.10.107:33490->142.251.152.119:443  # Google/YouTube
firefox gabriel 10.50.10.107:58624->151.101.65.42:443    # Reddit (151.101.x.x = Fastly/Reddit CDN)
firefox gabriel 10.50.10.107:39956->34.107.243.93:443    # Google services
steam   gabriel 10.50.10.107:39317->162.254.195.75:27018 # Steam (allowed)
gameoverl gabriel 127.0.0.1:53638->127.0.0.1:57343      # Ravenfield game (local, allowed)
Selective Process Kill
sudo pkill -u gabriel firefox (1)
1 -u gabriel kills only processes owned by that user. Firefox dies, Steam and the game stay running. DNS cache dies with the browser — reopening Firefox picks up the hosts file blocks.
Firefox Forensics — Browser History & Session Database

Firefox stores browsing history in SQLite databases under the user’s profile:

sudo find /home/gabriel/.mozilla/firefox -name 'places.sqlite' (1)
1 places.sqlite contains all browsing history, bookmarks, and visit counts. One file per Firefox profile.
Query browsing history (last 50 URLs)
PROFILE=$(sudo find /home/gabriel/.mozilla/firefox -name 'places.sqlite' -print -quit)
sudo sqlite3 "$PROFILE" "SELECT datetime(last_visit_date/1000000,'unixepoch','localtime') AS visited, url FROM moz_places WHERE last_visit_date IS NOT NULL ORDER BY last_visit_date DESC LIMIT 50;" (1)
1 last_visit_date is microseconds since epoch — divide by 1,000,000 for Unix timestamp. datetime() with 'localtime' converts to human-readable in local timezone.
Search history for specific domains
sudo sqlite3 "$PROFILE" "SELECT datetime(last_visit_date/1000000,'unixepoch','localtime'), url FROM moz_places WHERE url LIKE '%youtube%' OR url LIKE '%reddit%' ORDER BY last_visit_date DESC LIMIT 20;" (1)
1 Case-insensitive LIKE search — catches youtube.com, m.youtube.com, youtu.be, etc. This shows exactly what was accessed and when, even if the browser cache is cleared.
Count visits per domain (top 20)
sudo sqlite3 "$PROFILE" "SELECT SUBSTR(url, INSTR(url,'://')+3, INSTR(SUBSTR(url,INSTR(url,'://')+3),'/')-1) AS domain, SUM(visit_count) AS visits FROM moz_places WHERE visit_count > 0 GROUP BY domain ORDER BY visits DESC LIMIT 20;" (1)
1 Extracts the domain from the full URL using SUBSTR + INSTR (no regex in SQLite). Groups by domain and sums visit counts. Shows which sites get the most traffic — the real usage pattern.
Active session tabs (what’s open right now)
sudo python3 <<'EOF'
import json, lz4.block, sys, glob

profiles = glob.glob('/home/gabriel/.mozilla/firefox/*/sessionstore-backups/recovery.jsonlz4')
if not profiles:
    print("No active session found")
    sys.exit(0)

with open(profiles[0], 'rb') as f:
    magic = f.read(8)
    data = json.loads(lz4.block.decompress(f.read()))

for w in data.get('windows', []):
    for t in w.get('tabs', []):
        entries = t.get('entries', [])
        if entries:
            print(entries[-1].get('url', ''))
EOF
(1)
1 Firefox stores the active session as LZ4-compressed JSON. The recovery.jsonlz4 file contains every open tab across all windows. Requires python3 + python-lz4 — if lz4 isn’t installed: sudo apt install python3-lz4 (Ubuntu/Debian on the P50).
Monitoring Commands — Quick Reference
Task Command

What’s he browsing now?

sudo lsof -i -n -P | grep firefox | awk '{print $9}' | sort -u

Kill browser only

sudo pkill -u gabriel firefox

History (last 50)

sudo sqlite3 "$PROFILE" "SELECT datetime(last_visit_date/1000000,'unixepoch','localtime'), url FROM moz_places ORDER BY last_visit_date DESC LIMIT 50;"

YouTube attempts

sudo sqlite3 "$PROFILE" "SELECT * FROM moz_places WHERE url LIKE '%youtube%';"

Open tabs right now

LZ4 Python script above

Lock hosts file

sudo chattr +i /etc/hosts

Unlock hosts file

sudo chattr -i /etc/hosts

Verify lock

lsattr /etc/hosts

firefox-forensics-findings

Describe this sub-partial here.

Both options worked. Gabriel was watching Napoleon Dynamite clips at midnight — good to know. Now let me build this properly as a partial that covers all three learning domains: SQLite/database syntax, Linux process management, and browser security.

Browser Forensics, Process Management, and SQLite Queries

Finding the Firefox Profile

Firefox stores all history, bookmarks, and session data in a SQLite database called places.sqlite. Its location depends on how Firefox was installed.

find /home/$USER/.mozilla/firefox -name 'places.sqlite' 2>/dev/null (1)
find /home/$USER/snap/firefox -name 'places.sqlite' 2>/dev/null     (2)
1 Standard Firefox install path — used when installed via apt, pacman, or similar package managers. $USER expands to the current user’s name. 2>/dev/null suppresses permission errors so only real hits are shown.
2 Snap-sandboxed Firefox path — Ubuntu and derivatives install Firefox as a snap by default. The profile lives under ~/snap/firefox/common/ instead of ~/.mozilla/. Always check both when the first returns nothing.

Querying Another User’s Profile

When Firefox belongs to a different user (gabriel), two things change: you need sudo to read their files, and you need to know their snap path.

PROFILE=$(sudo find /home/gabriel/snap/firefox \  (1)
  -name 'places.sqlite' -print -quit)             (2)
echo "$PROFILE"                                    (3)
1 sudo find — required because /home/gabriel/ is owned by gabriel. Without sudo you get Permission denied and an empty result.
2 -print -quit — print the first match and stop immediately. Without -quit, find continues searching and may return multiple results if there are profile backups. We only want the live database.
3 Always echo "$PROFILE" before using it in a query. Verify the variable holds the path you expect before passing it to sqlite3.

The Database Lock Problem

Firefox holds an exclusive write lock on places.sqlite while it is running. Any direct query attempt returns:

Error: in prepare, database is locked (5)

Two safe approaches:

sudo cp "$PROFILE" /tmp/places-copy.sqlite  (1)
sudo sqlite3 /tmp/places-copy.sqlite "      (2)
SELECT datetime(last_visit_date/1000000,'unixepoch','localtime'), url
FROM moz_places
WHERE url LIKE '%youtube%'
   OR url LIKE '%reddit%'
ORDER BY last_visit_date DESC
LIMIT 20;"
1 Copy the database to /tmp while Firefox holds the lock. The copy succeeds because the filesystem allows reads even on a locked file — only SQLite’s own locking protocol prevents concurrent writers. The copy is a snapshot of the database at that moment.
2 Query the copy. No lock contention. Safe to run while Firefox is open. The data may be slightly behind what Firefox has buffered in its WAL (write-ahead log) but is accurate enough for history inspection.

Option B — Immutable URI (Query Live File Read-Only)

sudo sqlite3 \
  "file://$PROFILE?mode=ro&immutable=1" \ (1)
  "SELECT ..."
1 The file:// URI scheme with ?mode=ro&immutable=1 tells SQLite to open the file read-only and skip all locking checks entirely. immutable=1 is the critical flag — it tells SQLite "I promise nothing will change this file while I’m reading it." Use this only when you know Firefox will not write during your query. Option A is safer for production use.

The SQLite Query — Anatomy

SELECT
  datetime(last_visit_date/1000000, 'unixepoch', 'localtime'), (1)
  url                                                            (2)
FROM moz_places                                                  (3)
WHERE url LIKE '%youtube%'                                       (4)
   OR url LIKE '%reddit%'
ORDER BY last_visit_date DESC                                    (5)
LIMIT 20;                                                        (6)
1 last_visit_date is stored in microseconds since Unix epoch (January 1, 1970 UTC). Dividing by 1000000 converts to seconds. 'unixepoch' tells SQLite the input is a Unix timestamp. 'localtime' converts from UTC to the system’s local timezone. Without 'localtime' all times show as UTC.
2 url is the full URL string exactly as Firefox recorded it.
3 moz_places is Firefox’s main URL table. Every URL ever visited gets a row here. Related tables: moz_historyvisits (individual visit events), moz_bookmarks (bookmarks), moz_inputhistory (address bar searches).
4 LIKE '%youtube%' matches any URL containing the string youtube. The % is a wildcard meaning "zero or more of any character." This catches youtube.com, www.youtube.com, and any subdomain or path.
5 ORDER BY last_visit_date DESC — newest visits first. DESC means descending (highest number = most recent timestamp = shown first).
6 LIMIT 20 — return at most 20 rows. Always use a LIMIT when exploring an unfamiliar database. moz_places can contain hundreds of thousands of rows.

Explore the Full Database Schema

sudo sqlite3 /tmp/places-copy.sqlite ".tables"    (1)
sudo sqlite3 /tmp/places-copy.sqlite ".schema moz_places" (2)
sudo sqlite3 /tmp/places-copy.sqlite "
  SELECT name, sql FROM sqlite_master
  WHERE type='table'
  ORDER BY name;"                                  (3)
1 .tables is a SQLite dot-command (not SQL). Lists all table names in the database. Dot-commands start with . and are SQLite shell commands, not part of the SQL language.
2 .schema moz_places shows the CREATE TABLE statement for that table — every column name and its data type. This is how you discover what you can query without reading Firefox source code.
3 sqlite_master is SQLite’s internal catalog table. Every database has one. It stores the schema for all tables, indexes, triggers, and views. Querying it directly gives you the same information as .schema but in a format you can filter and sort with SQL.

Process Inspection and Session Management

See What Is Running as Another User

ps aux | grep -iE 'firefox|chrome|chromium' | grep -v grep (1)
ps -u gabriel --forest                                       (2)
1 ps aux lists every process on the system. a = all users, u = user-oriented format (shows username, CPU, memory), x = include processes not attached to a terminal. grep -v grep removes the grep process itself from the results.
2 --forest shows processes as a tree — you can see which processes are children of which parent. Firefox spawns many content processes; --forest shows them all branching from the main firefox PID.

Find the Main Firefox PID

pgrep -u gabriel firefox          (1)
pgrep -u gabriel -a firefox       (2)
cat /proc/$(pgrep -u gabriel firefox | head -1)/status (3)
1 pgrep searches for processes by name and returns PIDs only. -u gabriel filters to gabriel’s processes. Cleaner than ps aux | grep.
2 -a adds the full command line to the output so you can confirm which PID is the main process vs content processes.
3 /proc/PID/status is a virtual file in the Linux proc filesystem. It shows the process’s real state: name, PID, parent PID, memory usage, and which user owns it. This is where ps gets its data.

Close Firefox Gracefully for a Specific User

sudo -u gabriel DISPLAY=:0 wmctrl -c firefox  (1)
sudo kill -SIGTERM $(pgrep -u gabriel firefox | head -1) (2)
sudo kill -SIGKILL $(pgrep -u gabriel firefox | head -1) (3)
1 Sends a close-window request through the X11 window manager. Polite — Firefox gets a chance to save session state and flush the database. Requires wmctrl (apt install wmctrl). Use this first.
2 SIGTERM (signal 15) asks the process to terminate cleanly. Firefox handles this signal and shuts down gracefully, flushing all pending writes to places.sqlite before exiting.
3 SIGKILL (signal 9) is unconditional — the kernel kills the process immediately with no cleanup. Use only if SIGTERM does not work within a few seconds. Risk: the database WAL may not be flushed, leaving places.sqlite in an inconsistent state.

After Closing — Query the Live Database Directly

PROFILE=$(sudo find /home/gabriel/snap/firefox \
  -name 'places.sqlite' -print -quit)

sudo sqlite3 "$PROFILE" "
SELECT datetime(last_visit_date/1000000,'unixepoch','localtime'), url
FROM moz_places
ORDER BY last_visit_date DESC
LIMIT 30;"

No copy needed once Firefox is closed — the lock is released.

Browser Security Concepts

What places.sqlite Reveals

Table Contains

moz_places

Every URL ever visited — full URL, visit count, last visit timestamp

moz_historyvisits

Individual visit events — one row per visit, with referrer and transition type

moz_bookmarks

All bookmarks and folder structure

moz_inputhistory

What the user typed into the address bar (not always the full URL)

moz_cookies (separate file)

Active session cookies — in cookies.sqlite in the same profile directory

What This Means for Security

A standard user account on a shared Linux machine with sudo access can:

  • Read any other user’s complete browser history

  • Extract active session cookies from cookies.sqlite

  • Read saved form data and address bar history

This is expected behavior — sudo is designed to grant this level of access. The security boundary on a shared machine is sudo access itself, not the browser’s storage.

For a family machine where you want gabriel to have privacy from other standard users (but not from root/sudo), the filesystem permissions are already correct — /home/gabriel/ is 700 (owner only). Only sudo bypasses this.

COOKIES=/home/gabriel/snap/firefox/common/.mozilla/firefox/9srvkhla.default/cookies.sqlite
sudo cp "$COOKIES" /tmp/cookies-copy.sqlite
sudo sqlite3 /tmp/cookies-copy.sqlite "
SELECT host, name, datetime(expiry,'unixepoch','localtime') as expires
FROM moz_cookies
ORDER BY lastAccessed DESC
LIMIT 20;"

This shows active session cookies by most recently accessed. Cookie names like session_token, auth, sid, or JSESSIONID are session identifiers — possessing them is equivalent to being logged in as that user on that site.

Clean Up Temp Files

sudo rm /tmp/places-copy.sqlite /tmp/cookies-copy.sqlite

Always remove database copies from /tmp after use. /tmp is world-readable on most Linux systems.

Navigation Chain — moz_historyvisits

sudo sqlite3 /tmp/places-copy.sqlite ".schema moz_historyvisits"
Schema output
CREATE TABLE moz_historyvisits (
  id INTEGER PRIMARY KEY,
  from_visit INTEGER,    (1)
  place_id INTEGER,      (2)
  visit_date INTEGER,    (3)
  visit_type INTEGER,    (4)
  session INTEGER,
  source INTEGER DEFAULT 0 NOT NULL,
  triggeringPlaceId INTEGER
);
1 from_visit — the visit ID that led to this one. Join back to this same table to reconstruct the click chain: page A → page B → page C.
2 place_id — foreign key to moz_places.id. Join to get the actual URL.
3 visit_date — microseconds since epoch, same as moz_places.last_visit_date.
4 visit_type — 1=link click, 2=typed in address bar, 3=bookmark, 4=embed, 5=redirect (permanent), 6=redirect (temporary), 7=download.
Reconstruct navigation path (last 20 visits with referrer)
sudo sqlite3 /tmp/places-copy.sqlite "
SELECT datetime(v.visit_date/1000000,'unixepoch','localtime') AS visited,
       p.url AS destination,
       p2.url AS came_from
FROM moz_historyvisits v
JOIN moz_places p ON v.place_id = p.id
LEFT JOIN moz_historyvisits v2 ON v.from_visit = v2.id
LEFT JOIN moz_places p2 ON v2.place_id = p2.id
ORDER BY v.visit_date DESC
LIMIT 20;" (1)
1 Three-table JOIN: visit → destination URL + referrer visit → referrer URL. Shows exactly how he navigated — which link on which page led to where. LEFT JOIN handles direct navigation (typed URL, bookmark) where from_visit is 0.

Usage Analysis — gabriel-ThinkPad-P50 (2026-06-28)

Domain Visit Frequency

sudo sqlite3 -header -column /tmp/places-copy.sqlite " (1)
SELECT SUBSTR(url, INSTR(url,'://')+3,
              INSTR(SUBSTR(url,INSTR(url,'://')+3),'/')-1) AS domain,
       COUNT(*) AS visits,
       printf('%.1f%%', COUNT(*) * 100.0 / (SELECT COUNT(*) FROM moz_places WHERE visit_count > 0)) AS pct (2)
FROM moz_places
WHERE visit_count > 0
GROUP BY domain
ORDER BY visits DESC
LIMIT 20;"
1 -header -column — formatted table output with column headers and aligned spacing. Without these flags, SQLite defaults to | pipe-delimited which is functional but hard to read at a glance.
2 printf('%.1f%%') — percentage of total visits. The subquery (SELECT COUNT(*) …​) calculates the denominator inline. Double %% escapes the literal percent sign in SQLite’s printf.
Table 2. Results — 2026-06-28
Domain Visits Assessment

www.youtube.com

5,806

BLOCKED — 61% of all browsing. Hosts file now redirects to 127.0.0.1

lincolnlearningsolutions.lms.lincolnlearningsolutions.org

1,190

Allowed — school LMS (Lincoln Learning)

www.google.com

228

Allowed — search

pulse-api.llsapps.org

193

Allowed — Lincoln Learning API backend

www.youtubekids.com

149

BLOCKED — redirected to 127.0.0.1

api.agilixbuzz.com

62

Allowed — Lincoln Learning (Agilix Buzz LMS engine)

accounts.google.com

56

Allowed — Google auth

store.steampowered.com

51

Monitor — Steam store browsing (game purchases)

www.ixl.com

48

Allowed — math/education platform

www.khanacademy.org

20

Allowed — education

music.youtube.com

20

BLOCKED — YouTube Music, redirected to 127.0.0.1

chatgpt.com

17

Monitor — AI usage for schoolwork (verify not used for answers)

127.0.0.1:8080

16

Local — likely a dev server or game server

Validated Output — 2026-06-28

domain                                                     visits  pct
---------------------------------------------------------  ------  -----
www.youtube.com                                            5806    69.6%
lincolnlearningsolutions.lms.lincolnlearningsolutions.org  1190    14.3%
www.google.com                                             228     2.7%
pulse-api.llsapps.org                                      193     2.3%
www.youtubekids.com                                        149     1.8%
lls-prod-oh-pulse.auth.us-east-2.amazoncognito.com         111     1.3%
api.agilixbuzz.com                                         62      0.7%
accounts.google.com                                        56      0.7%
store.steampowered.com                                     51      0.6%
www.ixl.com                                                48      0.6%
global-zone20.renaissance-go.com                           39      0.5%
docs.google.com                                            37      0.4%
clever.com                                                 30      0.4%
www.khanacademy.org                                        20      0.2%
music.youtube.com                                          20      0.2%
login.renaissance.com                                      19      0.2%
zone20-student.renaissance-go.com                          18      0.2%
chatgpt.com                                                17      0.2%
127.0.0.1:8080                                             16      0.2%
cyber.gale.com                                             13      0.2%

YouTube (all variants: www + kids + music) = 71.6% of all browsing. Educational sites (Lincoln Learning + IXL + Khan Academy + Renaissance) = 16.3%. The blocking is justified.

Port 8080 Investigation

127.0.0.1:8080 appeared 16 times in browsing history. Investigated via lsof:

sudo lsof -i :8080 -u gabriel | head -10 (1)
1 -i :8080 filters to port 8080 only. -u gabriel scopes to his user. The output showed systemd process entries but no active listener — the service that ran on 8080 is no longer running. Likely a local game server or dev tool that was previously active.

Hourly Browsing Pattern

sudo sqlite3 -header -column /tmp/places-copy.sqlite "
SELECT strftime('%H', datetime(visit_date/1000000,'unixepoch','localtime')) AS hour,
       COUNT(*) AS visits,
       printf('%.*s', COUNT(*)/20, '████████████████████████████████████████████████████') AS bar (1)
FROM moz_historyvisits
GROUP BY hour
ORDER BY hour;"
1 Visual bar chart — printf('%.*s', n, string) prints n characters of the bar string. Dividing visit count by 20 scales the bar to fit the terminal. Same technique works against ISE DataConnect for auth timeline visualization.
Results — 2026-06-28
Hour  Visits  Pattern
────  ──────  ───────────────────────────────────────
 00     131   ██████                    ← after midnight
 01      38   █
 06      17
 07      91   ████
 08     231   ███████████
 09     694   ██████████████████████████████████
 10     624   ███████████████████████████████
 11     618   ██████████████████████████████
 12   1,149   ████████████████████████████████████████████████████████  ← peak: lunch
 13     731   ████████████████████████████████████
 14     600   ██████████████████████████████
 15     617   ██████████████████████████████
 16   1,017   ██████████████████████████████████████████████████  ← after school
 17   1,101   ██████████████████████████████████████████████████████
 18   1,072   █████████████████████████████████████████████████████
 19     789   ███████████████████████████████████████
 20     731   ████████████████████████████████████
 21   1,031   ███████████████████████████████████████████████████  ← evening peak
 22     628   ███████████████████████████████
 23     357   █████████████████

Key findings:

  • No browsing 02:00–05:00 — good, he’s sleeping

  • Peak at 12:00 (1,149) — lunch break, unsupervised

  • Heavy 16:00–21:00 (1,017–1,101 per hour) — after school through evening

  • 131 visits at midnight — needs a bedtime network cutoff

Late-Night Usage — Evidence

sudo sqlite3 -header -column /tmp/places-copy.sqlite "
SELECT datetime(visit_date/1000000,'unixepoch','localtime') AS visited,
       p.url
FROM moz_historyvisits v
JOIN moz_places p ON v.place_id = p.id
WHERE strftime('%H', datetime(visit_date/1000000,'unixepoch','localtime')) >= '22'
   OR strftime('%H', datetime(visit_date/1000000,'unixepoch','localtime')) < '06'
ORDER BY visit_date DESC
LIMIT 30;" (1)
1 strftime('%H') extracts the hour as a zero-padded string. The WHERE clause selects 22:00–05:59 — everything outside reasonable hours for a child. JOIN resolves the place_id to the actual URL.
Output — 2026-06-28
visited              url
-------------------  --------------------------------------------------------------------------
2026-06-28 01:09:57  https://www.youtube.com/watch?v=kQVxj0WGD4g
2026-06-28 00:59:17  https://www.youtube.com/watch?v=8Vom5GZdK_I
2026-06-28 00:59:12  https://www.youtube.com/watch?v=NnTycJg1MIo
2026-06-28 00:52:12  https://www.youtube.com/watch?v=da7AZFp7f8A
2026-06-28 00:46:42  https://www.youtube.com/watch?v=gducgTcJMMU
2026-06-28 00:44:49  https://www.youtube.com/watch?v=lsKRlVwXThU
2026-06-28 00:43:25  https://www.youtube.com/watch?v=L3LHAlcrTRA
2026-06-28 00:41:50  https://www.youtube.com/watch?v=bya709eOwg0
2026-06-28 00:40:51  https://www.youtube.com/watch?v=ROFeU9_tFf4
2026-06-28 00:40:42  https://www.youtube.com/results?search_query=napoleon+dynamite
2026-06-28 00:38:44  https://www.youtube.com/watch?v=BiAwpYIkRmU
2026-06-28 00:38:40  https://www.youtube.com/results?search_query=napoleon+dynamite+dance
2026-06-28 00:37:51  https://www.youtube.com/watch?v=mO1oBfG59Xw
2026-06-28 00:37:32  https://www.youtube.com/watch?v=OzTX8SVJtis
2026-06-28 00:37:16  https://www.youtube.com/watch?v=mEQvIdPnrUw
2026-06-28 00:36:42  https://www.youtube.com/watch?v=NZJrGuC92U8
2026-06-28 00:35:09  https://www.youtube.com/watch?v=pttdENG0X78
2026-06-28 00:35:03  https://www.youtube.com/results?search_query=kip+durag
2026-06-28 00:34:50  https://www.youtube.com/
2026-06-28 00:33:06  https://www.youtube.com/watch?v=jWYHk3E2qpE
2026-06-28 00:32:50  https://www.youtube.com/watch?v=mdcbiy0Ml7U
2026-06-28 00:32:44  https://www.youtube.com/results?search_query=ralph+sopranos+shopping+cart
2026-06-28 00:29:26  https://www.youtube.com/watch?v=3hLfGlz97Ic
2026-06-28 00:29:19  https://www.youtube.com/results?search_query=prison+break++how+goes+it+roy
2026-06-28 00:28:55  https://www.youtube.com/results?search_query=prison+how+goes+it+roy
2026-06-28 00:28:20  https://www.youtube.com/
2026-06-26 23:53:25  https://www.youtube.com/shorts/WgYhWWhM8MI
2026-06-26 23:44:08  https://www.youtube.com/shorts/WgYhWWhM8MI

Summary: 28 of 30 late-night entries are YouTube. Continuous session from 00:28 to 01:09 on 2026-06-28 — 41 minutes of YouTube after midnight. Search queries include Napoleon Dynamite clips, Sopranos scenes, and Prison Break. Previous night (2026-06-26) active at 23:44–23:53 watching YouTube Shorts. This pattern justifies a bedtime network cutoff.

Action Method Status

Block YouTube/TikTok/Reddit/Twitch

/etc/hosts + chattr +i

✓ Done — 2026-06-28

Monitor Steam store usage

Periodic sqlite3 domain query

Manual — consider blocking if grades drop

ChatGPT usage review

Query moz_places WHERE url LIKE '%chatgpt%' with full URL paths

Pending — check if used for homework answers

Bedtime network cutoff

Cron job: iptables rule or router-level block at 22:00

Planned — implement via VyOS firewall rule or P50 local iptables

Weekly usage report

Script: domain + hourly queries → formatted output → email or file

Planned — automate as scripts/parental-report.sh

yubikey-age-gopass-v4

YubiKey Age Identity + Gopass v4 Store

Hardware-bound secrets upgrade. Software age key on disk → YubiKey-backed identity where decryption requires physical touch. New gopass v4 store with age backend replaces GPG — eliminates keyring management, agent crashes, and subkey expiration as failure modes. Coexistence with v3 during natural credential rotation.

Prerequisites — Verified 2026-06-28
Component State Version

YubiKey

Detected — INC-004 USBGuard block resolved

5C NFC, FW 5.7.1, serial 31311804

PIV

Enabled (USB + NFC)

ykman

Working after usbguard allow-device

age-plugin-yubikey

Installed from official repos (extra/)

0.5.1-1

gopass

age backend supported

1.16.1

pcscd

Socket-activated, CCID functional

Phase 1 — Generate YubiKey-Backed Age Identity
age-plugin-yubikey (1)
1 Interactive setup — prompts to select a PIV slot (default: slot 82, Retired Key Management 1) and touch policy. Generates an age identity on the YubiKey and outputs the recipient (public key) and identity file path. The private key never leaves the YubiKey hardware.

Expected output:

Choose a slot (1-20, or press Enter for slot 1):
Using slot 82 (Retired Key Management 1)
Generated new key pair.

recipient: age1yubikey1q...
identity: age-plugin-yubikey-1...

Write the identity to a file? (y/n)

Save the identity file and recipient:

mkdir -p ~/.age/identities ~/.age/recipients
# The identity file goes to ~/.age/identities/ (points to the YubiKey — no secret material)
# The recipient (public key) goes to ~/.age/recipients/yubikey.txt
Phase 2 — Test Encrypt/Decrypt Cycle
echo "YubiKey age test — $(date)" | age -r "$(cat ~/.age/recipients/yubikey.txt)" > /tmp/yubikey-age-test.age (1)
age -d -i ~/.age/identities/age-identity-yubikey.txt /tmp/yubikey-age-test.age (2)
rm /tmp/yubikey-age-test.age
1 Encrypt with the YubiKey recipient (public key) — no hardware touch needed for encryption
2 Decrypt requires the YubiKey to be inserted AND touched (depending on touch policy configured in Phase 1). If the YubiKey blinks, touch it.
Phase 3 — Initialize Gopass v4 Store
gopass init --store v4 --crypto age (1)
gopass config --store v4 (2)
1 Creates a new gopass store named v4 with age backend (not GPG). Gopass will prompt for the age identity — provide the YubiKey identity file path.
2 Verify the store configuration — should show crypto: age and the correct identity.

Test with a throwaway entry:

gopass insert v4/test/hello (1)
gopass show v4/test/hello (2)
gopass rm v4/test/hello (3)
1 Insert a test secret — encrypted with age using the YubiKey recipient
2 Decrypt — requires YubiKey present + touch
3 Clean up test entry
Phase 4 — Coexistence Model

Both stores run in parallel:

v3/ → GPG-backed (current production, read-only going forward)
v4/ → age/YubiKey-backed (new entries, rotated credentials)
  • New credentials → v4/domains/d000/…​

  • Rotated credentials → new value in v4/, old stays in v3/ for rollback

  • dsource profiles → update one at a time from v3/ to v4/ paths

  • Natural migration via credential rotation cycle — no batch migration script needed

PIV PIN Recovery — Near-Lockout Incident (2026-06-28)
Timeline
Attempt Action Result

1

age-plugin-yubikey — entered 123456 (factory default)

Invalid PIN (2 tries remaining before it is blocked)

2

ykman piv access change-pin — entered 123456 as current

PIN change failed - 1 tries left

3

STOPPED — 1 try remaining, switched to PUK recovery

Avoided permanent lockout

Root Cause

The PIV PIN had been changed from the factory default at some point in the past. ykman piv info reported WARNING: Using default PIN! — this warning checks the default value, not whether it’s actually set to that value. The warning is misleading.

Recovery
ykman piv access unblock-pin (1)
# Enter PUK: <default 12345678>  (2)
# Enter a new PIN: <new pin>
# New PIN set.
1 unblock-pin bypasses the PIN entirely — uses the PUK (PIN Unblocking Key) to set a new PIN regardless of the current PIN state. This is the only recovery path when PIN tries are exhausted.
2 The PUK was still at factory default (12345678, 3/3 tries remaining). If the PUK had also been exhausted, the PIV applet would be permanently locked — no recovery without a full YubiKey reset (destroys all PIV keys and certificates).
Post-Recovery — Secure All Defaults
ykman piv access change-puk (1)
ykman piv access change-management-key --generate (2)
ykman piv info (3)
1 Change PUK from factory default 12345678 — if someone knows the default PUK, they can reset your PIN and access your PIV keys. Change immediately.
2 --generate creates a random AES192 management key and stores it on the YubiKey (protected by PIN). The default management key is published in every YubiKey guide — leaving it unchanged is equivalent to no management key.
3 Verify: all three warnings (default PIN, default PUK, default Management key) should be gone.
Store Credentials
gopass edit v3/domains/d000/security/yubikey/31311804 (1)
1 Store the new PIN, PUK, and management key in gopass. The management key is long (24 bytes hex) — do not try to memorize it. If you lose the PIN, only the PUK can recover. If you lose both, the PIV applet is bricked.
Recommended YAML structure
type: "yubikey"
serial: "31311804"
model: "YubiKey 5C NFC"
firmware: "5.7.1"
credentials:
  piv:
    pin: "<new-pin>"
    puk: "<new-puk>"
    management_key: "<generated-key>"
    _use: "PIV operations — age-plugin-yubikey, SSH certs, code signing"
  fido2:
    pin: "<if set>"
    _use: "WebAuthn, passkeys, SSH resident keys"
created: "2026-06-28"
updated: "2026-06-28"
Lessons
  • Never guess a PIV PIN — check tries remaining first (ykman piv info)

  • WARNING: Using default PIN! is unreliable — it checks against the known default, not actual state

  • PUK is the last resort — if both PIN and PUK are exhausted, PIV is permanently locked

  • Change all defaults immediately on a new YubiKey — PIN, PUK, and management key

  • Store in gopass before proceeding — the management key is unrecoverable if lost

v4 Store — Operational (2026-06-28)
Incident Chain to Operational
Blocker Root Cause Fix

ykman info — No YubiKey detected

USBGuard blocking CCID interface 0b:00:00 (INC-004)

sudo usbguard allow-device 58 + permanent append-rule

age-plugin-yubikey — Invalid PIN

PIV PIN changed from default at unknown point, 1 try remaining

ykman piv access unblock-pin via PUK (still default)

age-plugin-yubikey — management key error

AES192 management key unsupported by age-plugin-yubikey

ykman piv access change-management-key -a TDES --protect

age -d — permission denied on plugin

AppArmor profile on /usr/bin/age denies child execve of plugins (INC-005)

Added /usr/bin/age-plugin-* ix, to /etc/apparmor.d/usr.bin.age

gopass show — no identity matched

gopass internal identity keyring at ~/.config/gopass/age/identities — must be added via gopass age identities add, not manual file placement

gopass age identities add with identity token + recipient

Finding gopass identity path

Three guesses failed — age-identities.txt, env vars, raw file copy

strace -f -e openat gopass show revealed ~/.config/gopass/age/identities

Proof — strace Reveals the Path
strace -f -e openat gopass show v4/test/hello 2>&1 | grep -i 'age\|identity' (1)
1 The syscall trace showed openat("~/.config/gopass/age/identities") = -1 ENOENT — gopass looks for its internal encrypted identity keyring at this exact path. Not ~/.age/, not age-identities.txt, not environment variables. The only way to populate it correctly is gopass age identities add.
Correct Setup Procedure (for future reference)
gopass age identities add (1)
# Enter the age identity starting in AGE-:
# AGE-PLUGIN-YUBIKEY-1HNRA6QVZ8TWEDFSV4AGYG
# Provide the corresponding age recipient []:
# age1yubikey1qwg5w7vykg36czquwdseajl3rx07t0sdk4y6m96wfcep7k33gqpnu3x47yf

gopass show v4/test/hello (2)
# Enter secret: <PIN>
# 👆 Touch YubiKey
# test123
1 gopass age identities add encrypts the identity token into its own keyring format at ~/.config/gopass/age/identities. This is the ONLY supported way to add age identities — manual file creation does not work.
2 Decrypt requires PIN entry + physical YubiKey touch. The private key never leaves the hardware.
v3 → v4 Migration

Copy v3 entries to v4 — v3 stays untouched as read-only fallback:

gopass ls --flat v3/ | head -20 (1)

gopass show v3/domains/d000/identity/ise/ise-01 | gopass insert v4/domains/d000/identity/ise/ise-01 (2)

gopass show v4/domains/d000/identity/ise/ise-01 (3)
1 List v3 entries — see what needs migrating. No rush — migrate as you rotate credentials.
2 Pipe v3 decrypt (GPG) → v4 encrypt (age/YubiKey). The entry contents are identical — only the encryption backend changes. v3 is unmodified.
3 Verify — PIN + touch to decrypt from v4. Compare output with v3 to confirm integrity.

Batch copy (all v3 entries at once — optional, only when ready):

gopass ls --flat v3/ | while read -r entry; do
  printf 'Migrating: %s\n' "$entry"
  gopass show "$entry" | gopass insert "v4/${entry#v3/}" (1)
done
1 ${entry#v3/} strips the v3/ prefix — v3/domains/d000/…​ becomes v4/domains/d000/…​. Each entry is decrypted from v3 (GPG) and re-encrypted into v4 (age/YubiKey). Touch required for each entry (PIN policy: Always).

Coexistence:

v3/ → GPG-backed (read-only, rollback)
v4/ → age/YubiKey (primary, new entries)
  • New credentials → v4/

  • Rotated credentials → new value in v4/, old stays in v3/

  • dsource profiles → update from v3/ to v4/ one at a time

  • Retire v3 when all entries are in v4 and tested

Dual-Passphrase Architecture
The Two Layers
Layer 1: gopass keyring passphrase        ← something you KNOW (protects the identity token)
Layer 2: YubiKey PIN + physical touch     ← something you HAVE + DO (protects the private key)
Layer What It Protects Where It Lives

Keyring passphrase

The AGE-PLUGIN-YUBIKEY-…​ identity token in gopass’s internal encrypted keyring at ~/.config/gopass/age/identities

Your memory — not stored on disk in plaintext

YubiKey PIN

The P-256 private key burned into the YubiKey PIV slot

YubiKey hardware — entered per-decrypt (PIN policy: Always)

Physical touch

Authorization of each decrypt operation

Your finger on the YubiKey — cannot be automated or replayed

Why They Must Be Different

If the keyring passphrase equals the YubiKey PIN, compromising one compromises both layers. The security model collapses to single-factor:

  • Same password: attacker with PIN → opens keyring → has identity token → decrypts (only needs YubiKey physical access)

  • Different passwords: attacker with PIN → blocked at keyring. Attacker with keyring passphrase → blocked at PIN. Both required.

Comparison: v3 (GPG) vs v4 (age/YubiKey)
v3 (GPG) v4 (age/YubiKey)

Factor 1

GPG passphrase

Gopass keyring passphrase

Factor 2

YubiKey PIN

Factor 3

Physical touch

Key location

~/.gnupg/ (disk — software key)

YubiKey silicon (hardware — non-extractable)

Agent caching

gpg-agent caches passphrase for configurable TTL

No caching (PIN policy: Always)

Compromise path

Disk access → key extraction → offline brute-force

Physical YubiKey theft + PIN + keyring passphrase (three factors)

Changing the Keyring Passphrase — Executed 2026-06-28
gopass age identities (1)
# age1yubikey1qwg5w7vykg36czquwdseajl3rx07t0sdk4y6m96wfcep7k33gqpnu3x47yf

gopass age identities rm age1yubikey1qwg5w7vykg36czquwdseajl3rx07t0sdk4y6m96wfcep7k33gqpnu3x47yf (2)

gopass age identities add (3)
# Enter the age identity starting in AGE-:
# AGE-PLUGIN-YUBIKEY-1HNRA6QVZ8TWEDFSV4AGYG
# Provide the corresponding age recipient []:
# age1yubikey1qwg5w7vykg36czquwdseajl3rx07t0sdk4y6m96wfcep7k33gqpnu3x47yf
# (Set a NEW passphrase — DIFFERENT from YubiKey PIN)

gopass show v4/domains/d000/identity/ise/ise-01 (4)
1 Verify current identity — shows the recipient public key. Confirm before removing.
2 rm takes the recipient as argument — not the identity token. Removes the encrypted keyring entry.
3 Re-add with the same identity token and recipient but a new keyring passphrase. The identity pointer is unchanged — only the scrypt encryption wrapping it changes. Existing v4 entries remain accessible.
4 Test the full chain: new keyring passphrase (first prompt) → YubiKey PIN (second prompt) → physical touch → decrypted secret. All three factors must succeed.
Credential Storage

Store the keyring passphrase in v3 (GPG-protected) — ironic but practical. v3 is your working store and the keyring passphrase is needed to access v4:

gopass edit v3/domains/d000/security/gopass-v4-keyring (1)
1 Store the keyring passphrase here. If you forget it, you must gopass age identities rm + gopass age identities add to reset — existing v4 entries remain encrypted and accessible as long as the YubiKey identity (token + recipient) stays the same.
Recovery Scenarios
Scenario Recovery

Forgot keyring passphrase

gopass age identities rm + gopass age identities add with same identity token. Existing entries are NOT lost — they’re encrypted to the YubiKey recipient, not the keyring passphrase. The keyring only wraps the identity pointer.

Lost YubiKey

No recovery. The P-256 private key is non-extractable. Must re-encrypt all v4 entries with a new key (backup YubiKey or software age key). This is why the doc says "everything breaks, have a backup plan."

YubiKey PIN blocked (0 tries)

Use PUK to unblock: ykman piv access unblock-pin. If PUK also exhausted → PIV applet is permanently locked → same as lost YubiKey.

Both keyring + PIN known but touch fails

Physical YubiKey damage. Replace YubiKey, re-generate identity in same slot, re-add to gopass. Existing entries need re-encryption.

USB Port Root Cause — INC-006 Postmortem
The Real Problem

After hours of debugging AppArmor profiles, gopass keyring corruption, scrypt identity stores, pcscd socket access, /dev/tty permissions, and gopass Go age library limitations — the root cause was a flaky USB port.

The YubiKey CCID interface failed intermittently on USB port 3-1. Moving to a different physical USB-C port resolved all decrypt failures immediately.

What the Flaky Port Did
  • ykman info — worked (HID interface, not CCID-dependent)

  • lsusb — showed the device (kernel USB enumeration, not CCID)

  • usbguard list-devices — showed allow (policy layer, not transport)

  • pcscd — connected to socket successfully (daemon running)

  • age-plugin-yubikey — failed at CCID command level ("Could not open YubiKey")

  • Gopass — failed with "no identity matched" because the plugin silently failed

The CCID smart card interface has tighter timing and power requirements than HID (keyboard/FIDO). A marginal USB connection passes HID but fails CCID — the diagnostic tools all reported success because they use HID, not CCID.

What Actually Fixed It
# Moved YubiKey from USB port 3-1 to a different USB-C port
# Immediately:
gopass show v4/test/agent
# Enter secret: <keyring passphrase>
# waiting on yubikey plugin...
# Secret: v4/test/agent
# agent-test
Working Configuration (Confirmed 2026-06-28)
Component State

YubiKey

5C NFC, serial 31311804, FW 5.7.1, different USB-C port

AppArmor /usr/bin/age

age-plugin-* ix, + /dev/tty rw, + /dev/pts/* rw, + /run/pcscd/pcscd.comm rw,

AppArmor /usr/bin/gopass

age-plugin-* ix, + /dev/tty rw, + /dev/pts/* rw, + /run/pcscd/pcscd.comm rw, + ~/.config/gopass/** rw,

USBGuard

Rule 59: allow id 1050:0407 with CCID interface 0b:00:00

gopass config

age.agent-enabled = true

gopass age agent

Running, unlocked with keyring passphrase

gopass identity

AGE-PLUGIN-YUBIKEY-1HNRA6QVZ8TWEDFSV4AGYG registered via gopass age identities add

Store recipient

~/.local/share/gopass/stores/v4/.age-recipients = YubiKey public key

Decrypt Chain (Working)
gopass show v4/path/to/secret
  │
  ├── gopass decrypts scrypt keyring (~/.config/gopass/age/identities)
  │   → prompts: "Enter secret:" (keyring passphrase)
  │
  ├── gopass extracts AGE-PLUGIN-YUBIKEY identity token
  │
  ├── gopass calls age decrypt internally (Go library)
  │   → invokes age-plugin-yubikey subprocess
  │   → plugin connects to pcscd → pcscd talks to YubiKey via CCID
  │   → prompts: YubiKey PIN
  │   → prompts: physical touch
  │
  └── decrypted secret returned
Lessons
  • USB port quality matters for CCID — HID works on marginal connections, CCID doesn’t. If ykman info works but age-plugin-yubikey doesn’t, try a different port before debugging software.

  • AppArmor fixes were still necessary — the profile changes for age and gopass were real blockers. The USB port was the final blocker, not the only one.

  • strace can’t catch USB transport issues — the syscalls succeed (socket connect returns 0), the failure happens at the CCID protocol level inside the pcscd ↔ YubiKey communication.

  • Agent mode is requiredgopass config age.agent-enabled true + gopass age agent start + gopass age agent unlock eliminates the keyring decrypt race condition.

  • Document the working USB port — physical infrastructure matters. Label the port or note it.

dsource v4 Integration — Validated 2026-06-28
The Pipeline
PASS=$(gopass show v4/domains/d000/identity/ise/ise-01 | yq -r '.credentials.cli.password') (1)
sshpass -p "$PASS" ssh ise-01 'show version' 2>&1 | head -5 (2)
unset PASS (3)
1 gopass show v4/…​ triggers the full decrypt chain: keyring passphrase → YubiKey PIN → physical touch → age decrypt → YAML output. yq -r extracts the bare password string. The credential never touches disk.
2 sshpass feeds the password to SSH non-interactively. The ISE CLI responds with show version output. The password exists only in the shell variable $PASS — not in command history, not in /proc/$$/environ after unset.
3 unset PASS clears the variable from shell memory. Belt-and-suspenders — the variable would be cleared on shell exit anyway, but explicit cleanup is the standard.
Validated Output
Enter secret:           ← keyring passphrase
waiting on yubikey...   ← PIN + touch
admin connected from 10.50.1.204 using ssh on ise-01
Credential Flow — Zero Disk Exposure
YubiKey P-256 private key (hardware silicon)
  → age-plugin-yubikey decrypts .age file
    → gopass outputs YAML to stdout (pipe, not file)
      → yq extracts .credentials.cli.password (pipe)
        → $PASS shell variable (memory only)
          → sshpass feeds to SSH (stdin, not argv)
            → SSH tunnel (encrypted)
              → ISE CLI authenticated
                → unset PASS (memory cleared)

At no point does the password exist as a file on disk. The only exposure is the shell variable between assignment and unset — and that’s in process memory, not filesystem.

Next Steps
  • Update dsource profiles to prefer v4/ paths when available

  • Migrate remaining v3 SSH credentials to v4 (17 entries)

  • Add gopass age agent start to login shell (.zprofile or systemd user unit)

  • Test with sshpass pipelines for switch, WLC, and other NADs

  • Batch v3→v4 migration session (357 entries, weekend project)

Gopass v4 Remote Setup — Validated 2026-06-28
Repository Creation via API
ds d000 dev/app (1)

# GitHub
gh repo create gopass-v4 --private (2)

# GitLab
curl --header "PRIVATE-TOKEN: $GITLAB_PAT" \ (3)
  --data "name=gopass-v4&visibility=private" \
  "https://gitlab.com/api/v4/projects"

# Gitea
curl -X POST \
  -H "Authorization: token $GITEA_TOKEN" \ (4)
  -H "Content-Type: application/json" \
  -d '{"name":"gopass-v4","private":true}' \
  "https://gitea-01.inside.domusdigitalis.dev:3000/api/v1/user/repos"
1 ds (alias for dsource) loads API tokens from ~/.secrets/environments/domains/d000/dev/app.env.age. Check available tokens: env | grep -iE 'GITLAB|GITEA|GIT.*TOKEN' | awk -F= '{print $1"=<REDACTED>"}'
2 gh CLI for GitHub — simplest, no curl needed. --private creates a private repo.
3 GitLab uses $GITLAB_PAT (Personal Access Token), NOT $GITLAB_TOKEN. The PAT needs api scope for project creation. If 401 — token expired or wrong scope.
4 Gitea uses $GITEA_TOKEN. The response is verbose JSON — pipe through | jq '.html_url' for clean output.
Multi-Remote Push Pattern
git -C ~/.local/share/gopass/stores/v4 remote add origin git@github.com:EvanusModestus/gopass-v4.git (1)
git -C ~/.local/share/gopass/stores/v4 remote set-url --add --push origin git@github.com:EvanusModestus/gopass-v4.git (2)
git -C ~/.local/share/gopass/stores/v4 remote set-url --add --push origin git@gitlab.com:EvanusModestus/gopass-v4.git
git -C ~/.local/share/gopass/stores/v4 remote set-url --add --push origin git@gitea-01:evanusmodestus/gopass-v4.git
1 remote add sets the fetch URL — GitHub is the primary fetch source.
2 --add --push appends additional push URLs to the same remote name. One git push goes to all three. One git fetch comes from GitHub only.
Branch Rename + Initial Push
git -C ~/.local/share/gopass/stores/v4 branch -m master main (1)
git -C ~/.local/share/gopass/stores/v4 push -u origin main (2)
1 gopass git init defaults to master. Rename to main before first push.
2 -u sets upstream tracking. Pushes to all three remotes in sequence — prompts for SSH passphrase for each. Use gopass show v4/domains/d000/identity/ssh/github | yq -r '.passphrase' | wl-copy to copy passphrase to clipboard before pushing.
Verified State
git -C ~/.local/share/gopass/stores/v4 remote -v (1)
gopass sync (2)
1 Verify: one fetch (GitHub), three push (GitHub + GitLab + Gitea).
2 All four stores sync clean — root (no git), v2 (OK), v3 (OK), v4 (OK).
Output — 2026-06-28
origin    git@github.com:EvanusModestus/gopass-v4.git (fetch)
origin    git@github.com:EvanusModestus/gopass-v4.git (push)
origin    git@gitlab.com:EvanusModestus/gopass-v4.git (push)
origin    git@gitea-01:evanusmodestus/gopass-v4.git (push)
API Token Validation
GH_PAGER= gh repo view EvanusModestus/gopass-v4 --json url --jq '.url' (1)
curl -s -H "Authorization: token $GITEA_TOKEN" "https://gitea-01.inside.domusdigitalis.dev:3000/api/v1/repos/evanusmodestus/gopass-v4" | jq '.html_url' (2)
curl -s --header "PRIVATE-TOKEN: $GITLAB_PAT" "https://gitlab.com/api/v4/user" | jq '.username' (3)
1 GH_PAGER= disables the pager — gh opens less by default which hangs in pipelines.
2 Gitea API — confirmed working, repo exists.
3 GitLab PAT returned null — token expired or lacks api scope. Pending: regenerate PAT on gitlab.com/profile/personal_access_tokens.
Pending
  • Regenerate GitLab PAT with api scope — current token expired/invalid

  • Store new GitLab PAT in v4: gopass insert v4/domains/d000/identity/api/gitlab-pat

  • Update ~/.secrets/environments/domains/d000/dev/app.env.age with new PAT

yubikey-usbguard-block

YubiKey USBGuard Block — INC-004

Fourth security layer incident. USBGuard policy blocks the YubiKey’s CCID interface (0b:00:00), preventing pcscd and ykman from detecting the smart card. Same diagnostic pattern as INC-001/003 — misleading application error, real cause one layer deeper.

Symptoms
  • ykman infoERROR: No YubiKey detected!

  • lsusb sees Yubico.com Yubikey 4/5 OTP+U2F+CCID — kernel detects USB

  • dmesg shows Product: YubiKey OTP+FIDO+CCID — enumeration succeeds

  • pcscd starts then auto-exits — no readers found

  • AppArmor has no pcscd or ccid profile — not the blocker

  • USB permissions crw-rw---- root — correct

Root Cause — USBGuard blocking the device
sudo usbguard list-devices | grep -i yubi (1)
1 list-devices shows the current policy decision for each USB device. The key field is the first word after the device number: allow, block, or reject.
Output
58: block id 1050:0407 serial "" name "YubiKey OTP+FIDO+CCID" hash "+yHSjnnIMzjrazEwqcEGVKmguy5xWgoVqqAQUnLyL5E=" parent-hash "Kc8J9gPb2yey1PIXEe9+4eCOuAHxcR7EsZlau6dOQZM=" via-port "3-1" with-interface { 03:01:01 03:00:00 0b:00:00 } with-connect-type "hotplug"

Three interfaces on the device:

Interface Class Function

03:01:01

HID Keyboard

OTP — one-time password (touch → keystrokes)

03:00:00

HID Generic

FIDO/U2F — WebAuthn, SSH resident keys

0b:00:00

Smart Card (CCID)

PIV, OpenPGP, age-plugin-yubikey — this is what pcscd needs

USBGuard blocks the entire device. pcscd cannot open the CCID interface. ykman cannot communicate via CCID. The OTP and FIDO interfaces are also blocked, but those use the HID kernel driver directly — they may or may not work depending on the USBGuard policy for HID.

Fix — Allow YubiKey in USBGuard
Step 1 — Verify before
sudo usbguard list-devices | grep -i yubi
# Expected: "block"
Step 2 — Allow the device (temporary — until re-plug)
sudo usbguard allow-device 58 (1)
1 58 is the device number from list-devices. This allows the device for the current session. Re-plugging the YubiKey will block it again unless a permanent rule exists.
Step 3 — Verify ykman
ykman info (1)
1 Should now show device type, serial number, firmware version, and enabled applications (OTP, FIDO2, PIV, OpenPGP, OATH).
Step 4 — Make it permanent
sudo usbguard generate-policy | grep -i yubi (1)
sudo usbguard append-rule 'allow id 1050:0407 name "YubiKey OTP+FIDO+CCID" with-interface { 03:01:01 03:00:00 0b:00:00 } with-connect-type "hotplug"' (2)
sudo usbguard list-rules | grep -i yubi (3)
1 Generate a policy line from the current device state — verify the syntax before committing
2 append-rule adds a permanent allow rule to /etc/usbguard/rules.conf. The rule matches by vendor:product ID (1050:0407 = Yubico) AND interface classes — specific enough to not allow arbitrary USB devices.
3 Verify the rule was persisted
Step 5 — Verify after re-plug

Physically remove and re-insert the YubiKey, then:

sudo usbguard list-devices | grep -i yubi (1)
ykman info (2)
1 Should now show allow instead of block
2 Should show full device info
Pattern — Fourth Security Layer Incident
Incident Security Layer Application Blocked Resource

INC-001 (2026-06-25)

AppArmor

/usr/bin/curl

~/.secrets/certs/d000/ise/ROOT-CA.crt

INC-002 (2026-06-07)

systemd

wpa_supplicant

wired 802.1X (service not enabled)

INC-003 (2026-06-27)

AppArmor

/usr/bin/git

~/.local/share/gopass/stores/*/.git/HEAD

INC-004 (2026-06-28)

USBGuard

pcscd (CCID)

YubiKey smart card interface 0b:00:00

The pattern: security layer does its job correctly (blocking unauthorized access), but the error surfaces as a misleading application-level message (No YubiKey detected, not a git repository, trust anchors). The diagnostic is always the same — go one layer deeper than the application error.

std-025-compliance

STD-025 AsciiDoc Authoring Standard — Rollout

Created STD-025 (AsciiDoc Authoring Conventions) and rolled it out across 12 projects spanning education, infrastructure, and work tiers.

What STD-025 defines
  • 14 admonition roles — 5 standard + 9 custom CSS roles in domus-asciidoc-build

  • Code block pattern — prose → callouts → [IMPORTANT.concept][TIP.terminal]

  • Attribute discipline — all tool names, paths, references as attributes

  • Diagram conventions — source in diagrams/, SVGs in images/, pre-render to SVG

  • Progress tables[NOTE.progress] with teal gradient headers, N/10 status text

  • Preface blocks[NOTE.mission] with compass icon

  • Conditional visibilityifdef::show-std[] for author-only content

CSS admonition roles added to domus-asciidoc-build
Role Icon Color

[TIP.terminal]

fa-terminal

Teal

[TIP.exercise]

fa-pencil

Amber

[NOTE.strace]

fa-search

Indigo

[NOTE.reference]

fa-book

Slate

[NOTE.compile]

fa-cogs

Steel blue

[NOTE.mission]

fa-compass

Warm gold

[NOTE.progress]

fa-tasks

Cool teal

[WARNING.debug]

fa-bug

Rose

[IMPORTANT.concept]

fa-key

Deep blue

Projects brought to compliance
STD-025 Compliance Status
Project Tier Status

c-programming-language

d000

Compliant

systems-debugging

d000

Compliant

systems-mastery

d000

Compliant

text-mastery

d000

Compliant

programmability-automation

d000

Compliant

ise-api-reference

d000

Compliant

claroty-api-reference

d000

Compliant

keylogger

d000

Compliant

domus-infrastructure

d000

Compliant

mschapv2-to-cert-migration

d001

Compliant

browser-forensics

d000

Compliant

ideation-intake

d000

Compliant

How to open any project
# List all discoverable projects
cd ~/atelier/_bibliotheca/domus-captures && make build-data-list

# Build a specific project
make build-data PROJ=<slug>

# Build with private attributes (d000)
make build-data PROJ=ise-api ENV=env-d000

# Build d001 projects (decrypt first)
d001 open <slug>
make build-data PROJ=<slug> TIER=d001 ENV=env-d001
# verify, then:
d001 close <slug>

# Open in browser
firefox data/d000/<category>/<project>/output/<project>.html &
References
  • STD-025: docs/modules/ROOT/pages/standards/STD-025-asciidoc-authoring.adoc

  • STD-025 partial: docs/modules/ROOT/partials/standards/STD-025-asciidoc-authoring.adoc

  • Education standard: data/d000/education/EDUCATION-ASCIIDOC-STD.adoc

  • CSS roles: ~/atelier/_bibliotheca/domus-asciidoc-build/docinfo/css-light-cyan/admonitions.css

  • Companion to: STD-024 (AsciiDoc Build Toolchain)

vault-token-rotation

Vault SSH CA Cert Renewal + Key Rotation

Vault-signed SSH certificate expired June 22 (8-hour TTL). Key passphrase was set with gopass pwgen 32 during a 48-hour no-sleep session and never stored. Passphrase rotated, stored in gopass v4, cert re-signed, all three raft nodes verified.

Vault SSH CA Certificate Renewal — Validated 2026-06-28
The Problem
ssh-keygen -L -f ~/.ssh/id_ed25519_vault-cert.pub 2>&1 | grep Valid (1)
# Valid: from 2026-06-21T19:16:05 to 2026-06-22T03:16:35  ← EXPIRED 6 days ago
1 Vault-signed SSH certs have an 8-hour TTL. When expired, SSH falls back to key-based auth which vault-01/02/03 reject — they require CA-signed certificates. ykman info and ssh modestus-p50 work because those hosts accept the bare key, not the cert.
The Script
ds d000 dev/vault (1)
~/.local/bin/vault-ssh-sign (2)
1 Load Vault credentials — $VAULT_ADDR, $VAULT_TOKEN, $VAULT_CACERT. The script needs API access to the Vault cluster.
2 vault-ssh-sign — located in ~/atelier/_projects/personal/dots-quantum/bin/.local/bin/. Calls vault write -field=signed_key ssh/sign/domus-client with the correct principals and writes the cert to ~/.ssh/id_ed25519_vault-cert.pub.
Script output (2026-06-28)
[INFO] Using specified VAULT_ADDR: https://vault-01.inside.domusdigitalis.dev:8200
[INFO] Signing SSH key with Vault CA...
[INFO]   Key: /home/evanusmodestus/.ssh/id_ed25519_vault.pub
[INFO]   Principals: Administrator,domus\Administrator,adminerosado,admin,
                     ansible,evanusmodestus,gabriel,root,u0_a361
Enter passphrase for /home/evanusmodestus/.ssh/id_ed25519_vault:
Identity added: /home/evanusmodestus/.ssh/id_ed25519_vault (vault-signed-20260219)
Certificate added: /home/evanusmodestus/.ssh/id_ed25519_vault-cert.pub
Lifetime set to 08:04:22

[INFO] Certificate signed successfully:
          Type: ssh-ed25519-cert-v01@openssh.com user certificate
          Valid: from 2026-06-28T21:17:38 to 2026-06-29T05:18:08
Verification
ssh vault-01 "whoami" (1)
ssh vault-02 "whoami" (2)
ssh vault-03 "whoami" (3)
1 evanusmodestus — vault-01 accepts the fresh cert
2 evanusmodestus — vault-02 accepts
3 evanusmodestus — all three raft nodes accessible
Where Things Live
Component Location

Signing script

~/.local/bin/vault-ssh-sign (stowed from dots-quantum)

Test script

~/.local/bin/vault-ssh-test

Architecture docs

domus-infra-ops/…​/reference/vault-ssh-ca-architecture.adoc

Runbook

domus-infra-ops/…​/runbooks/vault-ssh-ca.adoc

D2 diagram

domus-infra-ops/…​/diagrams/vault-ssh-ca-flow.d2

Private key

~/.ssh/id_ed25519_vault (passphrase in gopass v4)

Certificate

~/.ssh/id_ed25519_vault-cert.pub (8-hour TTL, re-sign daily)

SSH config

~/.ssh/config.d/20-d000-key-hierarchy.conf

Vault credentials

ds d000 dev/vault (15 vars from dsec)

Key passphrase

gps v4/domains/d000/identity/ssh/id_ed25519_vault -r '.credentials.ssh.passphrase' | wl-copy

Daily Workflow
ds d000 dev/vault                                                              (1)
export SSH_ASKPASS=~/atelier/_bibliotheca/domus-captures/scripts/gopass-ssh-askpass.sh  (2)
export SSH_ASKPASS_REQUIRE=force
~/.local/bin/vault-ssh-sign                                                    (3)
ssh vault-01 "hostname"                                                        (4)
dsu                                                                            (5)
1 Load Vault API credentials into environment
2 Set askpass to gopass extractor — vault-ssh-sign gets the passphrase automatically via YubiKey (PIN + touch). No clipboard step.
3 Run signing script — passphrase supplied by askpass. Cert valid 8 hours.
4 Verify — should connect without "Permission denied"
5 dsu — unload Vault vars from environment when done. Credential hygiene.

gabriel-lua-gmod

Gabriel — Lua Programming via Garry’s Mod

Dad deployed a custom HUD addon to Gabriel’s GMod installation via SSH from the P16g. The addon replaces the default HUD with a custom health bar (color-changing), armor bar, weapon display, kill counter, and title text. Built in 5 incremental parts — each adds one feature so Gabriel sees it work before moving on. Every line has callouts explaining both the GMod concept and the real programming concept it teaches.

What is Lua?

Lua is a real programming language. It was designed in Brazil in 1993 and is used inside hundreds of games and applications because it is small, fast, and easy to embed. Garry’s Mod uses Lua for all of its addons and gamemodes. Roblox uses a variant called Luau. World of Warcraft uses Lua for its entire addon system.

This is not a toy language or a simplified "kid" version of programming. When you write Lua in GMod, you are writing the same kind of code that professional game developers write. The concepts — variables, functions, conditionals, loops, tables — transfer directly to Python, JavaScript, C, and every other language.

Console Access and Addon Structure

To open the GMod developer console, press ~ (tilde). If it does not open, go to Options > Keyboard > Advanced and enable "Enable Developer Console." The console shows print() output and error messages — this is how you debug.

Addon directory structure
garrysmod/addons/
└── my_first_addon/
    └── lua/
        └── autorun/
            └── client/
                └── my_hud.lua    <-- your code goes here

GMod automatically loads any .lua file inside addons/<name>/lua/autorun/client/ when the game starts. The client folder means this code runs on your machine (drawing your HUD), not on the server.

Part 1 — Your Name on Screen
hook.Add("HUDPaint", "GabrielHUD", function() (1)
    draw.SimpleText(                           (2)
        "// GABRIEL'S WORLD //",               (3)
        "DermaLarge",                          (4)
        ScrW() / 2, 30,                        (5)
        Color(255, 215, 0),                    (6)
        TEXT_ALIGN_CENTER
    )
end)
1 hook.Add("HUDPaint", …​) — run this function every frame (60+ fps). Event-driven programming.
2 draw.SimpleText() — draws text on screen. 6 args: text, font, x, y, color, alignment.
3 Change this to anything — your gamertag, a message, your mood.
4 Built-in font. Try "DermaDefault", "Trebuchet24".
5 ScrW() / 2 = center of screen. Works on any monitor — responsive design.
6 Color(R, G, B) — gold. Color(255,0,0) = red. Color(0,255,0) = green.
Part 1 — deploy just the title
GMOD="/home/gabriel/snap/steam/common/.local/share/Steam/steamapps/common/GarrysMod/garrysmod/addons"
sudo mkdir -p "$GMOD/my_first_addon/lua/autorun/client"
sudo tee "$GMOD/my_first_addon/lua/autorun/client/my_hud.lua" << 'LUA'
hook.Add("HUDPaint", "GabrielHUD", function()
    draw.SimpleText("// GABRIEL'S WORLD //", "DermaLarge",
        ScrW()/2, 30, Color(255,215,0), TEXT_ALIGN_CENTER)
end)
print("[GABRIEL] Part 1 loaded!")
LUA
sudo chown -R gabriel:gabriel "$GMOD/my_first_addon"
What just happened

You told GMod: "every frame, draw my name in gold." The game redraws it 60+ times per second. The screen doesn’t remember — it paints fresh every time. This is how ALL game HUDs work.

Part 2 — Health Bar That Changes Color
    local hp = LocalPlayer():Health()             (1)
    local hpColor                                 (2)
    if hp > 75 then                               (3)
        hpColor = Color(0, 255, 100)              -- green
    elseif hp > 25 then
        hpColor = Color(255, 200, 0)              -- yellow
    else
        hpColor = Color(255, 50, 50)              -- red!
    end

    draw.RoundedBox(8, 20, ScrH()-60,             (4)
        200, 40, Color(0,0,0,180))                -- dark background
    draw.RoundedBox(8, 20, ScrH()-60,
        200 * (hp / 100), 40, hpColor)            (5)
    draw.SimpleText("HP: " .. hp, "DermaLarge",   (6)
        120, ScrH()-55, Color(255,255,255),
        TEXT_ALIGN_CENTER)
1 LocalPlayer() = YOU. Ask it questions: :Health(), :Armor(), :Nick(). Object-oriented programming.
2 Variable — a container that holds a value. local means it exists only here.
3 if/elseif/else — computer makes a decision. Same in Python, JS, C. Conditional logic.
4 draw.RoundedBox(radius, x, y, width, height, color) — the dark background.
5 200 * (hp / 100)THE KEY. Health 100 = full bar. Health 50 = half. Health 10 = sliver. Linear interpolation — math making your game visual.
6 "HP: " .. hp.. joins text. String concatenation. Python: f"HP: {hp}". Bash: "HP: $hp".
Part 2 — deploy title + health bar
GMOD="/home/gabriel/snap/steam/common/.local/share/Steam/steamapps/common/GarrysMod/garrysmod/addons"
sudo tee "$GMOD/my_first_addon/lua/autorun/client/my_hud.lua" << 'LUA'
hook.Add("HUDPaint", "GabrielHUD", function()
    draw.SimpleText("// GABRIEL'S WORLD //", "DermaLarge",
        ScrW()/2, 30, Color(255,215,0), TEXT_ALIGN_CENTER)
    local hp = LocalPlayer():Health()
    local hpColor
    if hp > 75 then hpColor = Color(0,255,100)
    elseif hp > 25 then hpColor = Color(255,200,0)
    else hpColor = Color(255,50,50) end
    draw.RoundedBox(8, 20, ScrH()-60, 200, 40, Color(0,0,0,180))
    draw.RoundedBox(8, 20, ScrH()-60, 200*(hp/100), 40, hpColor)
    draw.SimpleText("HP: "..hp, "DermaLarge", 120, ScrH()-55,
        Color(255,255,255), TEXT_ALIGN_CENTER)
end)
print("[GABRIEL] Part 2 loaded!")
LUA
Decision trees

The if/elseif/else block is a decision tree. The computer evaluates conditions top to bottom and takes the first branch that matches. High health = green. Medium = yellow. Low = red. Every program you have ever used — games, apps, websites — is built on millions of these decisions.

Part 3 — Armor Bar
    local armor = LocalPlayer():Armor()          (1)
    if armor > 0 then                            (2)
        draw.RoundedBox(8, 230, ScrH()-60, 200, 40, Color(0,0,0,180))
        draw.RoundedBox(8, 230, ScrH()-60, 200*(armor/100), 40,
            Color(50, 150, 255))                 (3)
        draw.SimpleText("ARMOR: "..armor, "DermaLarge",
            330, ScrH()-55, Color(255,255,255), TEXT_ALIGN_CENTER)
    end
1 :Armor() — same pattern as :Health(). Another method on the player object.
2 Only draw if you have armor. Guard clause — skip work that isn’t needed.
3 Bright blue — armor is traditionally blue in games.
Part 3 — deploy title + health + armor
GMOD="/home/gabriel/snap/steam/common/.local/share/Steam/steamapps/common/GarrysMod/garrysmod/addons"
sudo tee "$GMOD/my_first_addon/lua/autorun/client/my_hud.lua" << 'LUA'
hook.Add("HUDPaint", "GabrielHUD", function()
    draw.SimpleText("// GABRIEL'S WORLD //", "DermaLarge",
        ScrW()/2, 30, Color(255,215,0), TEXT_ALIGN_CENTER)
    local hp = LocalPlayer():Health()
    local hpColor
    if hp > 75 then hpColor = Color(0,255,100)
    elseif hp > 25 then hpColor = Color(255,200,0)
    else hpColor = Color(255,50,50) end
    draw.RoundedBox(8, 20, ScrH()-60, 200, 40, Color(0,0,0,180))
    draw.RoundedBox(8, 20, ScrH()-60, 200*(hp/100), 40, hpColor)
    draw.SimpleText("HP: "..hp, "DermaLarge", 120, ScrH()-55,
        Color(255,255,255), TEXT_ALIGN_CENTER)
    local armor = LocalPlayer():Armor()
    if armor > 0 then
        draw.RoundedBox(8, 230, ScrH()-60, 200, 40, Color(0,0,0,180))
        draw.RoundedBox(8, 230, ScrH()-60, 200*(armor/100), 40, Color(50,150,255))
        draw.SimpleText("ARMOR: "..armor, "DermaLarge", 330, ScrH()-55,
            Color(255,255,255), TEXT_ALIGN_CENTER)
    end
end)
print("[GABRIEL] Part 3 loaded!")
LUA
Part 4 — Weapon + Kill Counter
    local wep = LocalPlayer():GetActiveWeapon()  (1)
    if IsValid(wep) then                         (2)
        draw.SimpleText(wep:GetPrintName(),      (3)
            "DermaLarge", ScrW()-30, ScrH()-55,
            Color(200,200,200), TEXT_ALIGN_RIGHT)
    end
    draw.SimpleText("KILLS: "..LocalPlayer():Frags(), (4)
        "DermaDefault", ScrW()-30, 30,
        Color(255,100,100), TEXT_ALIGN_RIGHT)
1 GetActiveWeapon() — the weapon you’re holding. An object with its own methods.
2 IsValid(wep) — safety check. You might be dead. Without this = crash. Null checking.
3 wep:GetPrintName() — asks the weapon for its name. : = method call on an object.
4 Frags() — kill count. Updates live every kill.
Part 5 — Hide Default HUD
hook.Add("HUDShouldDraw", "HideDefaultHUD",     (1)
    function(name)
        local hide = {                           (2)
            ["CHudHealth"] = true,
            ["CHudArmor"] = true,
            ["CHudAmmo"] = true,
        }
        if hide[name] then return false end       (3)
    end)
print("[GABRIEL] Custom HUD loaded!")             (4)
1 GMod asks "should I draw this default element?" Your function answers.
2 A table — Lua’s dictionary. Keys = HUD element names to hide.
3 Return false = don’t draw it. Elements NOT listed draw normally.
4 print() — outputs to console. See this = script loaded. Debugging.
Full Deploy
Full script — deploy the complete HUD (all 5 parts)
GMOD="/home/gabriel/snap/steam/common/.local/share/Steam/steamapps/common/GarrysMod/garrysmod/addons"
sudo mkdir -p "$GMOD/my_first_addon/lua/autorun/client"
sudo tee "$GMOD/my_first_addon/lua/autorun/client/my_hud.lua" << 'LUAEOF'
hook.Add("HUDPaint", "GabrielHUD", function()
    draw.SimpleText("// GABRIEL'S WORLD //", "DermaLarge",
        ScrW()/2, 30, Color(255,215,0), TEXT_ALIGN_CENTER)
    local hp = LocalPlayer():Health()
    local hpColor
    if hp > 75 then hpColor = Color(0,255,100)
    elseif hp > 25 then hpColor = Color(255,200,0)
    else hpColor = Color(255,50,50) end
    draw.RoundedBox(8, 20, ScrH()-60, 200, 40, Color(0,0,0,180))
    draw.RoundedBox(8, 20, ScrH()-60, 200*(hp/100), 40, hpColor)
    draw.SimpleText("HP: "..hp, "DermaLarge", 120, ScrH()-55,
        Color(255,255,255), TEXT_ALIGN_CENTER)
    local armor = LocalPlayer():Armor()
    if armor > 0 then
        draw.RoundedBox(8, 230, ScrH()-60, 200, 40, Color(0,0,0,180))
        draw.RoundedBox(8, 230, ScrH()-60, 200*(armor/100), 40, Color(50,150,255))
        draw.SimpleText("ARMOR: "..armor, "DermaLarge", 330, ScrH()-55,
            Color(255,255,255), TEXT_ALIGN_CENTER)
    end
    local wep = LocalPlayer():GetActiveWeapon()
    if IsValid(wep) then
        draw.SimpleText(wep:GetPrintName(), "DermaLarge",
            ScrW()-30, ScrH()-55, Color(200,200,200), TEXT_ALIGN_RIGHT)
    end
    draw.SimpleText("KILLS: "..LocalPlayer():Frags(), "DermaDefault",
        ScrW()-30, 30, Color(255,100,100), TEXT_ALIGN_RIGHT)
end)
hook.Add("HUDShouldDraw", "HideDefaultHUD", function(name)
    local hide = {["CHudHealth"]=true,["CHudArmor"]=true,["CHudAmmo"]=true}
    if hide[name] then return false end
end)
print("[GABRIEL] Custom HUD loaded!")
LUAEOF
sudo chown -R gabriel:gabriel "$GMOD/my_first_addon"
echo "Done — restart GMod or type: lua_openscript_cl autorun/client/my_hud.lua"
Challenges
Start easy — each teaches something new
  1. Change the title — your gamertag instead of "// GABRIEL’S WORLD //"

  2. Change the color — red: Color(255, 0, 0). Purple: Color(150, 0, 255)

  3. Add ammoLocalPlayer():GetActiveWeapon():Clip1()

  4. Add a clockos.date("%H:%M:%S")

  5. Speed displaymath.Round(LocalPlayer():GetVelocity():Length())

  6. Rainbow titleHSVToColor(CurTime() * 50 % 360, 1, 1) instead of a fixed color

What You’re Learning
GMod Real Programming

hook.Add

Event-driven — code responds to events

Color(R,G,B)

RGB — same as CSS, Photoshop

ScrW() / 2

Responsive design — works on any screen

if/elseif/else

Conditionals — same in Python, JS, C

LocalPlayer():Health()

OOP — objects have methods

200 * (hp/100)

Linear interpolation — mapping ranges

"HP: " .. hp

String concatenation

IsValid(wep)

Null checking — prevent crashes

{["key"]=true}

Hash tables — Python dict, JS object

print()

Debugging — first tool every dev learns

This is real programming

You wrote an event-driven, responsive, object-oriented program with conditionals, string ops, null checking, and data structures. Every concept transfers to Python, JavaScript, C. This is where many developers started.

Gabriel’s Arsenal — 6 Custom Weapons

Deployed 6 weapons to Gabriel’s GMod installation via lua/autorun/gabriel_arsenal.lua. Uses weapons.Register() instead of the addon SWEP system — bypasses snap filesystem mounting issues.

Deployment History
Attempt Method Result

v1 — addon folder

addons/gabriel_weapons/lua/weapons/gabriel_tactical_rifle/shared.lua

SWEP nil error. Addon mounted (file.Exists = true) but weapon class not registered. Snap sandbox issue with addon loading.

v2 — autorun registration

lua/autorun/gabriel_arsenal.lua with weapons.Register()

Bypasses addon system entirely. Single file, 6 weapons, auto-loads on startup.

Snap sandbox lesson

GMod installed via Snap has filesystem isolation. The addon folder exists and files are readable (file.Exists returns true), but the addon mounting system doesn’t register weapons from it. The lua/autorun/ directory is always loaded — putting weapon registration there bypasses the addon system entirely. weapons.Register(table, "classname") does what SWEP.* does in addon mode, but works from any Lua context.

Weapons
Console Command Weapon Features

give gabriel_rifle

Tactical Rifle

Auto fire, iron sights (right-click zoom), tracers, recoil, custom ammo HUD

give gabriel_gravity

Gravity Cannon

Left-click launches props, right-click pulls them — physics force scaled by mass

give gabriel_crossbow

Explosive Crossbow

Fires bolts that detonate after 2 seconds — 250 radius blast

give gabriel_lightning

Lightning Gun

Hold to zap — continuous beam, 800 unit range, electric effects

give gabriel_airstrike

Airstrike Marker

Aim + click = warning bell → 5 sequential explosions at target

give gabriel_railgun

Railgun

One-shot 500 damage sniper, 2 sec reload, right-click 15 FOV scope

Deploy Command
Deploy all 6 weapons from dad’s machine
scp /tmp/gmod-deploy-v2.sh modestus-p50:/tmp/
ssh modestus-p50 'bash /tmp/gmod-deploy-v2.sh'
File Location
/home/gabriel/snap/steam/common/.local/share/Steam/
  steamapps/common/GarrysMod/garrysmod/
    lua/autorun/gabriel_arsenal.lua    ← all 6 weapons in one file
How Gabriel Uses Them
  1. Restart GMod completely (quit + reopen from Steam)

  2. Open console: ~

  3. Look for [GABRIEL] Arsenal loaded! 6 weapons ready.

  4. Type: give gabriel_rifle (or any weapon name)

  5. Or open Q menu → "Gabriel’s Arsenal"

js-yaml-dependabot-dismiss

js-yaml Dependabot Alert — Dismissed (Tolerable Risk)

GitHub Dependabot alert #10 on domus-captures: js-yaml ⇐4.1.1 quadratic-complexity DoS via repeated merge key aliases (GHSA-h67p-54hq-rp68).

Assessment
Field Value

CVE

GHSA-h67p-54hq-rp68

Severity

Moderate (DoS, not RCE)

Package

js-yaml@4.1.1

Locked by

@antora/cli@3.1.15, @antora/playbook-builder@3.1.15, @antora/content-aggregator@3.1.15, @antora/ui-loader@3.1.15

Fix available

No — Antora has not released a patched version

Attack vector

Crafted YAML with repeated merge key aliases

Exposure

None — all YAML input is author-controlled (antora.yml, antora-playbook.yml). No untrusted input path.

Decision
Why this is tolerable

Dismissed as tolerable risk. The vulnerability requires an attacker to feed crafted YAML to the parser. In domus-captures, all YAML is authored by the project owner — there is no user-submitted, API-ingested, or externally-sourced YAML. The DoS has no path to trigger.

Re-evaluate when Antora releases a version with js-yaml > 4.1.1.

Dismiss Command
gh api repos/EvanusModestus/domus-captures/dependabot/alerts/10 \
  -X PATCH \
  -f state=dismissed \
  -f dismissed_reason="tolerable_risk" \
  -f dismissed_comment="DoS via crafted YAML — all YAML input is author-controlled. No untrusted input path. Waiting for Antora upstream fix."

Ad-Hoc Requests

Ad-Hoc Requests

Capture walk-ups, Teams pings, and unplanned work here.

*


Work (CHLA)

CHARGE TIME IN PEOPLESOFT - CRITICAL. Do this NOW before anything else.

Critical (P0)

Project Description Owner Status Due Blocker

Linux Research (Xianming Ding)

EAP-TLS for Linux workstations, dACL, UFW

Evan

BEHIND (72 days overdue)

02-24

Certificate "password required" - nmcli fix documented

iPSK Manager

Pre-shared key automation

Ben Castillo

BEHIND

 — 

DB replication issues

MSCHAPv2 Migration

Legacy auth deprecation — 6,227 devices, 5 waves. 6 batch SQL queries + 3-API endpoint profile script added (05-11). Report due.

Evan

25% — Report due, batch queries ready

05-30

Report to turn in

Research Segmentation

All endpoints to Untrusted VLAN

Evan

BLOCKED

 — 

CISO decision pending

Disaster Recovery

ISE DR scoping — dot1x closed mode = total blackout

Evan

Scoping

 — 

 — 

Mandiant Remediation

Copy 4/16 findings, Guest ACL lab, Q2 assessment

Evan

Active

 — 

 — 

SIEM QRadar → Sentinel

Full SIEM platform transition. Monad console error resolved 05-12. Secrets configured. Blocked on DCR creation (Rule ID + Stream Name). Azure private network policy unresolved.

Evan

Active — blocked on DCR

Q2 2026

Victor/Mauricio: create DCR, resolve Azure network policy

Abnormal Security

AI email platform — ESA cutover. CR assigned, CAB May 12 15:00. Implementation May 14 10:00.

Evan

Active — CAB today 15:00

05-14

Pre-CAB checklist: confirm Tyler, Jason, Sarah

High Priority (P1)

Project Description Owner Status Target

ISE 3.4 Migration

Upgrade from 3.2p9

Evan

Blocked — maintenance window needed

Q2 2026

Switch Upgrades

IOS-XE fleet update (C9300, 3560CX)

Evan

Pending

Q2 2026

Spikewell BYOD VPN

dACL SQL, AD group integration

Evan

Active

 — 

Strongline Gateway

MAC capture, Identity Group setup — 37 days aging

Evan

Active — David Rukiza assigned

 — 

Abnormal Security

AI email security platform research, ESA cutover timeline

Evan

Newly assigned

 — 

DMZ Migration

External services audit behind NetScaler

Evan

Audit phase

 — 

Firewall Audit (murus-portae)

EtherChannel query, prefilter, policy assignments

Evan

Scoping — ASA API creds needed

 — 

iPSK Manager HA

Server 2 config, TLS, SQL security audit

Evan

In progress

 — 

Sentinel KQL

Build proficiency, distinguish from team

Evan

Onboarding

 — 

VNC Blocking

Block and eliminate VNC protocol enterprise-wide

Evan

Active — Phase 0 (Discovery)

Mid-June 2026

Strategic (P2)

Project Description Owner Status

HHS Regulatory Compliance

New HHS security policies implementation

TBD

NOT STARTED

InfoSec Reporting Dashboard

PowerBI metrics for executives

TBD

NOT STARTED

EDR Migration (AMP → Defender)

Endpoint protection consolidation

TBD

NOT STARTED

Azure Legacy Migration

Modern landing zone

Team

In Progress

ChromeOS EAP-TLS

SCEP + Victor, Paul testing

Victor

In Progress

P0 — Critical / Blocking

Security & Compliance

  • ISE 3.2 Patch 10 upgrade — CVE-2026-20147 CVSS 9.9 / CVE-2026-20148. Propose maintenance window once patch confirmed on software.cisco.com.

  • ISE Advisory sa-ise-rce-traversal-8bYndVrZ — check Patch 10 availability

  • Mandiant Remediation — findings status tracked. Working session prep + defensive posture documented (comms-2026-04-24). Copy 4/16 updates into Excel at work. Guest ACL lockdown (WIR-M-01) pending lab validation. appendix-todos updated with MSCHAPv2 milestones.

  • Guest ACL update — guest redirect ACL work needed. Lab validate GUEST_CWA_REDIRECT_MAX_SECURITY in d000, then joint CR with NE. On today’s task list.

  • Disaster Recovery & Downtime Procedures — ISE top priority (dot1x closed mode = SPOF for network access)

    • ISE DR: Document failover sequence — PAN, MnT, PSN priority order

    • ISE DR: RADIUS dead-server detection on WLCs/switches — critical-auth VLAN fallback

    • ISE DR: Backup/restore procedures — scheduled config backups, tested restores

    • FTD/FMC DR: FMC loss = no policy management

    • Network DR: Core/distribution switch failure, STP reconvergence, HSRP failover

    • Document RTO/RPO per system

SIEM Migration (QRadar → Sentinel)

  • SIEM QRadar → Sentinel Migration — LEAD ROLE. 4 collection iterations (Apr 16, 17, 17-streamlined, 20-streamlined). Python chart pipeline built (qradar-charts.py). Migration XLSX generated. Verification pending. Comms sent Apr 23.

    • d001 artifacts: 8 JSON exports, 2 CSV inventories, migration XLSX, top5 source SVG/PNG, verification doc

    • Dependency: Monad pipeline for log source transition

    • Dependency: Sentinel KQL proficiency for query migration

  • Monad Pipeline Evaluation (origin: 2026-03-11) — lead role. Console error RESOLVED 05-12. 06-09: Architecture decision — rsyslog (CHLXSYSLOG01) as collection tier → Monad → Sentinel. ISE lab → rsyslog → Monad 6-step execution guide created with 10 API calls. ASA lab logs already flowing through rsyslog. DCR still needed — Victor + Mauricio.

  • Sentinel KQL — build proficiency, distinguish from team. Azure portal access acquired.

  • QRadar log source report — run AQL queries, fetch JSON, generate Python Excel

Active Deployments & Migrations

  • MSCHAPv2 Migration — 6-sheet Standard Report ready. Migration window 05-04 to 05-30 CLOSED. Confirm final report status and next steps with team. 6,227 MSCHAPv2 devices, 14,249 EAP-TLS/TEAP (70% migrated).

  • MSCHAPv2 weekly cadence — recurring Wednesday call established (first 04-22). Completed 2026-04-22.

  • MSCHAPv2 ownership matrix — sent in scoping email 4/24 with manager callouts (@Albert, @John). Completed 2026-04-24.

  • TCP Clocks deployment — Batch 1: 7 clocks validated (OUI 40:AC:8D). Batch 2 (06-09): 9 new MACs (OUI 40:AC:BD) added via ERS. 1 one-off reassigned. New switch deployed without RADIUS/AAA — clocks can’t authenticate. Switch onboarding template + 3 validation queries documented. ERS queries self-contained with ers function.

  • SRT Research VLAN — confirm roles with Tony Sun: Tony implementor, Evan tester. CAB approved 04-21.

  • Downtime Computers enforcement — draft ISE AuthZ rule: medigate_724 + Wireless = DenyAccess. Separate CR. d001: DC queries, audit CSVs (v1-v3), wireless violations report delivered 04-21.

  • Enterprise Linux 802.1X — standardize Shahab/Ding deployment (CISO priority). Overdue since 02-24. Blocked by nmcli cert fix. 06-15: Assembler build fixed (26 errors → 0). Segmentation document extension to 06-16. d001 open linux-research to work on it.

  • Abnormal Security — CR-2026-05-07. Implemented 05-13. 06-09 update: Full policy review — 20-section EOP validation commands rebuilt, Hoxhunt SCL-1 investigation (intentional bypass confirmed), sclizer junk folder triage (~800 emails), Outlook reactions audit added, Connect-ExchangeOnline msalruntime fix documented. ESA migration expansion in progress — priority to move off ESA to full environment.

    • Team: Cox/William, Landeros/Jason, Rosado/Evan, Naranjo/Mauricio, Sandoval/Carlos

  • ASA VPN: Okta RADIUS → Entra SAML — (NEW 06-09) 5-phase migration plan built. ASA baseline captured (2 tunnel groups: CHLA_CORPORATE_USERS, CHLA_BYOD_USERS). 6 ISE policy screenshots. Tony Sun (ASA), Justin Halbmann (Entra/Okta), Evan (ISE). VPN cert expires 07-28. PDF deliverable ready. Share with team this week.

Tube System Upgrade (NEW — 06-01)

  • Tube System Upgrade — iTrack 3528165. 15x 10" TS stations need MAC addresses added to ISE identity group IoT_Onboard. MACs received from vendor (C8:1A:FE:20:xx:xx series). Station list spans ICU (CTICU, PICU, BMT, NICU, NICCU), ED, Surgery, Trauma, Pharmacy. Vendor contact: John Genest. Rationale: manufacturer no longer supports current system; failure risks delayed/missed patient care.

BMS Controller Segmentation (MIGRATED — 06-09)

  • BMS Controller Segmentation — Full migration from Principia LaTeX to d001. 12 partials, 5 Mermaid diagrams, 4 legacy PDFs, ISE screenshots. d001 open bms-controller. Completed 2026-06-09.

BMS Device Inventory (NEW — 04-24)

  • BMS Device Inventory — 72 devices discovered across 37 switches (04-24). Profile-driven architecture (Claroty/Medigate). 16 queries built. Phase 0 complete. Next: cross-reference with Visio diagrams, classify by function, begin D2 diagrams. Cleanup: delete 4 orphaned test groups, migrate 4 retire-dACL devices, investigate 3 null-profile devices.

VNC Blocking (NEW — 05-11)

  • VNC Blocking — block and eliminate VNC enterprise-wide. Due mid-June 2026. Phase 0: discovery. January AQL query baseline to incorporate. Cross-reference BMS inventory for VNC-capable devices.

Investigations & Audits

  • Murus Portae (WAF) — Phase 0 discovery in progress. FMC cert expired. d001: DMZ NetScaler WAF investigation, zone map, architecture D2 diagrams (v1+v2 SVGs), FMC REST API reference guide, ops script. FMC API returning zero ACP rules — under investigation.

  • Firewall audit — FMC discovery inventory done (d001: fmc-discovery-2026-04-16). EtherChannel query, prefilter, policy assignments pending.

  • IoT Dr. Kim devices — RECURRING. All 4 MACs validated in IoT_iPSK_VLAN1620_Misc (04-24). v2 validation queries built with 7 deep analysis queries (group flapping, credential leakage, profile drift, NAS tracking, remediation timeline, deny audit, OUI scan). Revalidate — confirm no flapping since 04-24.

  • IoT device validation queries — v2 created with partials architecture, 16 queries across ERS/MnT/DataConnect/FMC. Completed 2026-04-24.

Stale Blockers (carried via carryover tracker)

  • k3s NAT verification — rule 170, 10.42.0.0/16 pod network (origin: 2026-03-09). 92 days. Blocks Wazuh indexer recovery → blocks SIEM visibility. Decide: test or defer to Q3.

  • Strongline Gateway VLAN fix — 8 devices wrong identity group (origin: 2026-03-16). 85 days. David Rukiza assigned — follow up on status.

Administrative

  • PeopleSoft — track time for current week

  • iTrack tickets — close open tickets

  • KQL library — build initial queries in codex + d001

  • Linux Research project — finalize and review

  • Tax filing 2025 (MFJ) — see encrypted case file in data/d000/personal/ for details and action items

P1 — Important

  • MSCHAPv2 action-item tracker — owner/status/next-steps per workstream

  • ISE admin MFA enforcement — recommendation tied to advisory (interim control pending Patch 10)

  • DMZ Migration — external services audit behind NetScaler. Linked to Murus Portae investigation.

  • Vocera/Wyse iTrack RCA — complete root cause report

  • GCC ISE Support — 3/4 nodes restored, PSN-04 deferred

  • Wazuh indexer recovery — blocked by k3s NAT (origin: 2026-03-09)

  • Vocera EAP-TLS Supplicant Fix (origin: 2026-03-12)

  • iPSK Manager HA — blocked by DB replication (Ben Castillo)

  • ISE 3.4 Migration — depends on Patch 10 completion first

  • Git history scrub — murus-portae-output.md + ise-analytics CSVs

  • Encrypt prep-cmds-2026-04-15.adoc — plaintext committed to git

  • ISE MnT Messaging Service — enable UDP syslog delivery (maintenance window needed)

Infrastructure (Personal)

  • Borg backups — test and validate on ALL systems (Razer, P16g, vault-01, bind-01, kvm-01, kvm-02)

  • Borg — verify backup script paths updated from dotfiles-optimus to dots-quantum

  • Borg — create initial archive for ThinkPad P16g if none exists

  • Libvirt VLAN hook debug on both KVMs

  • Te1/0/2 cable replacement and re-test

  • Vault Raft cluster — verify vault-01 rejoined

  • Fix EAP-TLS keyring/secrets issue on Razer workstation

Completed (confirmed — do not delete, archive only)

  • CR-2026-04-15 SRT Research VLAN — submitted to iTrack. Completed 2026-04-15.

  • CAB presentation 4/21 — SRT Research VLAN 233 → CHLA-Research. APPROVED. Completed 2026-04-21.

  • Downtime Computers wireless audit — 45 computers, 16 violating, v3 report delivered. Completed 2026-04-21.

  • Git identity fix — dots-quantum/git/.gitconfig email corrected. Completed 2026-04-21.

  • MSCHAPv2 10:30 meeting — next steps + ACL coordination. Completed 2026-04-17.

Service Requests (SR)

SR# Request Requestor Opened Status

3508542

Zoll cards connection issue

STALE — verify in iTrack

3508524

Disable dot1x on (2) network ports - 5th floor 3250 Wilshire (PXE-boot imaging issues)

STALE — verify in iTrack (issues persisted after disable)

3528165

Tube System Upgrade — 15 stations, MAC addresses for ISE IoT_Onboard identity group

Genest, John (vendor contact)

2026-06-01

NEW — MACs received, need ISE onboarding

Incidents (INC)

INC# Priority Description Opened SLA Status

1911859

Strongline Gateways in Miscellaneous Subnet

STALE — verify in iTrack (related to carryover P0)

Change Requests - Emergency (ECAB)

CR# Description Opened Scheduled Status

No emergency changes

Change Requests - Normal

CR# Description Opened Scheduled Status

No normal changes

Change Requests - Scheduled/Standard

CR# Description Opened Window Status

No scheduled changes

Change Requests - Root Cause / Post-Incident

CR# Description Related INC Opened Status

100451

Vocera Phones and Wyse devices went off network

STALE — verify in iTrack


Session Accomplishments (Claude Code)

Day-specific accomplishments here.


Personal

In Progress

Project Description Status Notes

k3s Platform

Production k3s cluster on kvm-01

Active

Prometheus, Grafana, Wazuh deployed

Wazuh Archives

Enable archives indexing in Filebeat

Active

PVC fix pending

kvm-02 Hardware

Supermicro B deployment

Active

Hardware ready, RAM upgrade done

Planned

Project Description Target Blocked By

Vault HA (3-node)

vault-02, vault-03 on kvm-02

Q2 2026 (slipped from Q1)

kvm-02 deployment

k3s HA (3-node)

Control plane HA

Q2 2026 (slipped from Q1)

kvm-02 deployment

ArgoCD GitOps

k3s GitOps deployment

After k3s stable

 — 

MinIO S3

Object storage for k3s

After ArgoCD

 — 

Domus Inventory

Personal asset management (YAML + CLI + AsciiDoc)

Q2 2026

Schema approved

Active — Infrastructure

Task Details Priority Status Due

Wazuh agent deployment

Deploy agents to all infrastructure hosts

P2

Pending

After archives fix

k3s Platform

Production k3s cluster on kvm-01

P1

In Progress

 — 

Wazuh Archives

Enable archives indexing in Filebeat, PVC fix

P1

In Progress

 — 

kvm-02 Hardware

Supermicro B deployment, RAM upgrade done

P1

In Progress

 — 


Active — Security & Encryption

Task Details Priority Status Due

Configure 4th YubiKey

SSH FIDO2 keys

P1

TODO

 — 

Cold storage M-DISC backup

age-encrypted archives

P1

TODO

After YubiKey setup


Active — Development & Tools

Task Details Priority Status Due

netapi Commercialization

Go CLI rewrite with Cobra-style argument discovery, package for distribution

P0

Active

 — 

Ollama API Service

FastAPI (17 endpoints), productize — config audit, doc tools, runbook gen

P0

Active

 — 

Shell functions (fe, fec, fef)

File hunting helpers

P3

TODO

 — 


Active — Documentation

Task Details Priority Status Due

D2 Catppuccin Mocha styling

domus-* spoke repos (177 files total)

P3

In Progress

 — 


Active — Financial

Task Details Priority Status Due

Amazon order history import

Download CSV from Privacy Central → parse with awk → populate subscriptions tracker

P1

Waiting

Pending Amazon data export (requested 2026-04-04)


Active — Education

Task Details Priority Status Due

No active education tasks — see education trackers


Active — Personal & Life Admin

Task Details Priority Status Due

ThinkPad T16g Setup

Arch install, stow dotfiles, Ollama stack, netapi dev env

P0

Pending

 — 

P50 Arch to Ubuntu migration

CR-2026-03-12

P2

In Progress

 — 

X1 Carbon Ubuntu installs

2 laptops, LUKS encryption

P2

In Progress

 — 

P50 Steam Test

Test Flatpak Steam + apt cleanup of broken i386 packages

P3

Pending

 — 

Documentation Sites

Notes

Day-specific personal notes here.


Education

Claude Code Mastery

Resource Details Progress Status

Claude Code Full Course (4 hrs)

Nick Saraev - YouTube comprehensive course

26:49 / 4:00:00

IN PROGRESS

Claude Code Certification

Anthropic official certification (newly released)

Not started

GOAL

Skills Mastery (Critical)

Certification Deadlines

  • CISSP - July 12, 2026 (10-week plan active — Week 1)

  • RHCSA 9 - Q3 2026 (after CISSP)

  • LPIC-1 - Renewal required (blocks LPIC-2)

Spanish C1 Certification Goals

Certification Provider Target Status Strategy

SIELE C1

Instituto Cervantes / UNAM / Salamanca

Q2 2026

ACTIVE

Computer-based, faster results - take FIRST

DELE C1

Instituto Cervantes

Q3/Q4 2026

PLANNED

After SIELE success, harder exam

DELE C2

Instituto Cervantes

2027

FUTURE

Mastery level - requires extensive immersion

SIELE is computer-adaptive, results in 3 weeks. DELE is paper-based, results in 3-4 months. Do SIELE first to validate readiness.

Don Quijote Writing Practice - DELE C1/C2 Initiative

Method:

  1. Read chapter in original Spanish

  2. Write personal analysis/understanding en espanol

  3. AI review for grammar, vocabulary, register

  4. Build comprehensive understanding of literary elements

Today’s Study

  • Focus: CISSP (41 days to July 12 exam — schedule exam today 06-01), MSCHAPv2 migration wrap-up

  • Secondary: RHCSA curriculum, Spanish SIELE C1

  • CISSP — Security & Risk Management (continuing). Schedule exam this afternoon.

  • RHCSA — continue curriculum phase

  • Spanish — Don Quijote reading + analysis (DTLA study day)

  • MSCHAPv2 — migration window closed 05-30, review final report

Regex Training (CRITICAL)

  • Status: 52 days carried over (since 2026-03-16)

  • Priority: After PeopleSoft, before Quijote

  • Session: Character classes, word boundaries


Infrastructure

Documentation Sites

Site URL Status Actions Needed

Domus Digitalis

docs.domusdigitalis.dev

Active

Validate, harden, improve

Architectus

docs.architectus.dev

Active

Public portfolio site - maintain

HA Deployment Status

System Description Status Notes

VyOS HA

vyos-01 (kvm-01) + vyos-02 (kvm-02) with VRRP VIP

✅ COMPLETE

2026-03-07 - pfSense decommissioned

BIND DNS HA

bind-01 (kvm-01) + bind-02 (kvm-02) with AXFR

✅ COMPLETE

Zone transfer operational

Vault HA

Raft cluster (vault-01/02/03)

✅ COMPLETE

Integrated with PKI

Keycloak Rebuild

keycloak-01 corrupted, rebuild from scratch

🔄 NEXT

Priority P3 - SSO broken

FreeIPA HA

ipa-02 replica planned

📋 PLANNED

Linux auth redundancy

AD DC HA

home-dc02 replication

📋 PLANNED

Windows auth redundancy

iPSK Manager HA

ipsk-mgr-02 with MySQL replication

📋 PLANNED

PSK portal redundancy

ISE HA

PAN HA (ise-01 reconfigure)

⏳ DEFERRED

Wait until ise-02 stable

ISE 3.5 Migration

Upgrade path: 3.2p9 → 3.4 (P1) → 3.5 (target)

📋 PLANNED

After 3.4 Migration completes (Q2 2026)

Single Points of Failure (CRITICAL)

These systems have NO redundancy - outage impacts production.
System Impact if Down Mitigation

ISE (ise-02)

All 802.1X stops - wired and wireless auth fails

ise-01 reconfiguration deferred until ise-02 stable

Keycloak (keycloak-01)

SAML/OIDC SSO broken (ISE admin, Grafana, etc.)

NEXT PRIORITY - Rebuild runbook

FreeIPA (ipa-01)

Linux auth, sudo rules, HBAC fails

ipa-02 replica planned

AD DC (home-dc01)

Windows auth, Kerberos, GPO fails

home-dc02 replica planned

iPSK Manager

Self-service PSK portal unavailable

ipsk-mgr-02 with MySQL replication planned

Validation Tasks

Task Details Status

docs.domusdigitalis.dev validation

Test all cross-references, search, rendering

TODO

docs.domusdigitalis.dev hardening

HTTPS, CSP headers, security review

TODO

docs.architectus.dev validation

Public site content review

TODO

Hub-spoke sync verification

All components building correctly

Ongoing


Quick Commands

Git & GitHub CLI

create GitHub repo from existing local repo
gh repo create <name> --private --source . --remote origin --push
clone a forked repo into a specific directory
gh repo clone EvanusModestus/PowerShell ~/atelier/_projects/work/PowerShell
gh repo clone defaults to SSH. If key is passphrase-protected, load agent first: eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_ed25519_github
cross-repo commit search — all domus repos on a specific date
for repo in ~/atelier/_bibliotheca/domus-*/ ~/atelier/_projects/personal/domus-*/; do
  [ -d "$repo/.git" ] || continue
  name=$(basename "$repo")
  git -C "$repo" log --since="2026-04-06" --until="2026-04-07" --format="%h %aI %s" 2>/dev/null |
    awk -v r="$name" '{print r, $0}'
done
commit history touching only today’s modified files
git log --oneline -- $(find . -name "*.adoc" -type f -newermt "$(date +%F)")
unstage a file without losing changes
git restore --staged data/d001/api/ise-dataconnect/output/output-2026-04-24

Safe — removes from staging area only. Working tree is untouched. Use when you accidentally git add a plaintext or output file.

gh CLI — repo discovery and filtering

list repos by name pattern (domus/antora ecosystem)
gh repo list --limit 100 --json name,description \
  | jq -r '.[] | select(.name | test("domus|antora|asciidoc"; "i")) | "\(.name)\t\(.description)"'
top 20 most recently updated repos
gh repo list --limit 100 --json name,description,updatedAt \
  | jq -r 'sort_by(.updatedAt) | reverse | .[:20] | .[] | "\(.updatedAt[:10])\t\(.name)\t\(.description)"'
top 10 repos by disk usage
gh repo list --limit 100 --json name,diskUsage \
  | jq -r '.[] | "\(.diskUsage)\t\(.name)"' | sort -rn | head -10
clone a repo that’s not local yet
gh repo clone EvanusModestus/<repo-name> ~/atelier/_bibliotheca/<repo-name>

find & grep

files modified since midnight today (precise — not "last 24 hours")
find . -name "*.adoc" -type f -newermt "$(date +%F)" | sort
-mtime 0 means "last 24 hours", not "today". -newermt "$(date +%F)" compares against midnight — exact.
case-insensitive file search
find . -iname "*mschap*" -type f | sort
multiple name patterns with -o
find . -type f \( -iname "*ise*" -o -iname "*mschap*" \) | sort
same thing, single regex — fewer parens, extensible
find . -type f -iregex '.*\(ise\|mschap\).*'
exclude directories
find . -type f -iname "*meeting*" \
  -not -path "*/node_modules/*" \
  -not -path "*/.git/*" \
  -not -path "*/build/*"
recent drafts by modification time (newest first)
find .drafts -type f -printf '%T@ %Tc %p\n' | sort -rn | awk '{$1="";print}' | head -3
grep — know what you’re counting
grep -rl "pattern" . --include="*.adoc"         # file count (which files)
grep -rn "pattern" . --include="*.adoc"         # line matches (every occurrence)
grep -rc "pattern" . --include="*.adoc" | grep -v ':0$'  # match count per file
search with context — avoid opening the file
grep -rn -E 'git init|gh repo create' docs/ --include='*.adoc' -B2 -A2

Search codex by content — which files contain a command?

find all PowerShell files that use a specific cmdlet
find docs/modules/ROOT/examples/codex/powershell -type f -name "*.adoc" \
  -exec grep -l 'Get-Process\|Start-Process\|pipeline\|Where-Object' {} \;

Pattern: find -exec grep -l returns only filenames with matches — like grep -rl but with find’s `-type f -name filtering. Use \| for OR in grep basic regex. Swap the pattern for any cmdlet or keyword to locate coverage across the codex.

inventory a codex tool directory — count files per tier
find docs/modules/ROOT -name "powershell" -type d \
  -exec sh -c 'echo "$1: $(find "$1" -type f | wc -l) files"' _ {} \;
find orphaned examples (not included by any page)
for f in $(find docs/modules/ROOT/examples/codex/powershell -name "*.adoc" -type f); do
  base=$(basename "$f")
  dir_parent=$(basename $(dirname "$f"))
  grep -rq "$dir_parent/$base" docs/modules/ROOT/pages/codex/powershell/ \
    docs/modules/ROOT/examples/codex/powershell/*.adoc 2>/dev/null \
    || echo "ORPHAN: $f"
done

find → grep → open in nvim

find by path + content, open result in nvim
nvim $(find -path '*oauth*' -name '*.adoc' -type f \
  -exec grep -l 'timeout\|expire\|reconfig\|token' {} \;)

Command substitution $(…​) feeds all matches as arguments to nvim — opens every hit as a buffer. :bn/:bp to cycle, :ls to list. One file? Opens directly. Five files? All loaded, ready to navigate.

find by content across entire tree, open in nvim
nvim $(find docs/modules/ROOT -name '*.adoc' -type f \
  -exec grep -l 'token.*expire\|oauth.*refresh' {} \;)
open one at a time (sequential — -exec nvim per match)
find -path '*oauth*' -name '*.adoc' -type f \
  -exec grep -l 'timeout\|expire' {} \; \
  -exec nvim {} \;
Trailing \| in grep patterns matches empty string — every file matches. Always end with a term, not a pipe: 'timeout\|expire\|token' not 'timeout\|expire\|token\|'.

Trace Antora partial inclusion chains

who includes this partial? (one level up)
grep -rl 'commands/shell' docs/modules/ROOT/partials/
count all pages that include a partial
grep -rl 'quick-commands' docs/modules/ROOT | wc -l
full chain: partial → assembler → every page that uses it
file="commands/shell"
grep -rl "$file" docs/modules/ROOT/partials/ | while read f; do
  parent=$(basename "$f" .adoc)
  echo "$file -> $parent"
  grep -rl "$parent" docs/modules/ROOT/pages/ | while read p; do
    echo "  -> $(basename "$p")"
  done
done

Pattern: grep -rl finds which files contain the string. Chain two passes — first finds the assembler partial, second finds every page that includes it. Works for any partial in the Antora include hierarchy.

Multi-pattern file search — worklog partial discovery

brute force — one find per partial name
find docs/modules/ROOT -name "*urgent.adoc*" -type f
find docs/modules/ROOT -name "*morning.adoc*" -type f
consolidated — single find with regex (production approach)
find docs/modules/ROOT -type f -regextype posix-extended \
  -regex '.*(urgent|morning|work-chla|personal|education|infrastructure|quick-commands|related)\.adoc' \
  | sort

Pattern: -regextype posix-extended enables | alternation without escaping. One process, one sort — versus 8 separate finds. The sort deduplicates visually and groups by path.

pipeline alternative — find piped to grep
find docs/modules/ROOT -type f -name "*.adoc" \
  | grep -E 'urgent|morning|work-chla|personal|education|infrastructure|quick-commands|related'

Trade-off: the pipeline version is more readable but spawns two processes. The regex version is a single find — faster on large trees, same result.

Cross-repo literary term search — bibliotheca-wide discovery

When searching for a term across the entire _bibliotheca (multiple repos, mixed file types), these patterns escalate from narrow to broad.

1. Single repo — count matches per file
grep -rn --include='*.adoc' -c 'sanchuelo' . | grep -v ':0$'
2. Cross-repo — filenames only (all bibliotheca)
grep -rl --include='*.adoc' -i 'sanchuelo' ~/atelier/_bibliotheca/ | sort
3. Cross-repo with context — see the line in situ
grep -rn --include='*.adoc' -i -B1 -A1 'sanchuelo' ~/atelier/_bibliotheca/domus-captures/
4. Multi-filetype — .adoc + .txt (catches source texts)
grep -rl -i 'sanchuelo' ~/atelier/_bibliotheca/ --include='*.txt' --include='*.adoc' | sort
5. Null-safe find + xargs — handles spaces in paths
find ~/atelier/_bibliotheca/ -type f \( -name '*.adoc' -o -name '*.txt' \) -print0 \
  | xargs -0 grep -li 'sanchuelo' | sort
6. Open all hits directly in nvim
grep -rl -i 'sanchuelo' ~/atelier/_bibliotheca/ --include='*.adoc' --include='*.txt' | xargs nvim

Pattern escalation: #1 confirms the term exists and where. #2 expands to all repos. #3 shows context without opening files. #4 adds plain text sources (Quijote .txt originals). #5 is the safe version for automation. #6 opens everything for editing.

Trade-off: grep -r --include is faster for known file types. find | xargs grep is safer for paths with spaces and more extensible (add -name '*.md' etc.). For literary searches across the bibliotheca, #4 or #5 is usually the right starting point — the source texts are .txt, not .adoc.

Search daily notes — find commands, builds, and patterns across worklogs

Source: 2026-06-22 — searching for build-antora-page invocations and theme usage across partials

which daily note partials mention a command?
# Find files — filenames only
grep -rl 'build-antora-page' docs/modules/ROOT/partials/worklog/
see the match in context without opening the file
# 3 lines of context around each hit
grep -rn -C3 'build-antora-page' docs/modules/ROOT/partials/worklog/
search for any theme flag across all partials (PCRE alternation)
grep -rPn 'theme\s+(light-cyan|catppuccin|mocha)' docs/modules/ROOT/partials/
find + grep — search all daily note partials for build/export patterns
find docs/modules/ROOT/partials/worklog/daily-notes -name '*.adoc' \
  -exec grep -l 'build-antora-page\|build-adoc\|--theme\|pdf' {} \;
find + awk — show filename:matching_line for every hit (no -r needed)
find docs/modules/ROOT/partials/worklog/daily-notes -name '*.adoc' \
  -exec awk '/build-antora-page|build-adoc|--theme/{print FILENAME": "$0}' {} \;
awk state machine — extract full [source,bash] blocks containing a command
find docs/modules/ROOT/partials -name '*.adoc' -exec \
  awk '/^\[source,bash\]/{block=1; buf=""} \
       block{buf=buf"\n"$0} \
       /^----$/ && block>1{if(buf~/build-antora-page/) print FILENAME":"buf; block=0} \
       block{block++}' {} \;

The awk state machine: [source,bash] sets block=1 and starts buffering. Each line appends to buf. When the closing ---- arrives (and block>1 to skip the opening fence), check if the buffer contains the target command. If yes, print filename + entire block. Reset. One pass, arbitrary block size, no temp files.

sed address range — print only lines between code fences matching a pattern
find docs/modules/ROOT/partials/worklog -name '*.adoc' -exec \
  sed -n '/\[source,bash\]/,/^----$/{/build-antora-page/p}' {} +
search data/ too — standalone adoc outside Antora
grep -rn 'build-antora-page\|build-adoc' data/ --include='*.adoc'
every invocation sorted by path (chronological by date directory)
grep -rn 'build-antora-page' docs/modules/ROOT/partials/worklog/daily-notes/ \
  | awk -F: '{print $1, $3}' | sort

Pattern escalation: grep -rl → "where is it". grep -rn -C3 → "what’s around it". find -exec awk → "extract the structured block". sed address ranges → "print between delimiters". Each tool has a different affordance — grep finds, awk extracts structure, sed filters ranges, find -exec scales across trees.

Email thread analysis — extract people, dates, commitments, silence

who’s in the thread (@ mentions + From headers)
grep -P '(@\w+|^From:.*<)' comms.adoc
timeline — every date with context
grep -nP '\d{1,2}/\d{1,2}/\d{2,4}|20\d{2}-\d{2}-\d{2}' comms.adoc
commitments — who promised what
grep -niP '(I can |I will |I.ll |we will |we.ll )' comms.adoc
open questions and unknowns
grep -niP '(\?|need to confirm|need to validate|TBD|pending)' comms.adoc

comm — set difference (who hasn’t replied)

# All recipients
grep -oP '<\K[^>]+' comms.adoc | sort -u > /tmp/all-recipients

# All senders
grep -P '^From:' comms.adoc | grep -oP '<\K[^>]+' | sort -u > /tmp/replied

# Who's silent — follow-up targets
comm -23 /tmp/all-recipients /tmp/replied

comm -23 outputs lines only in file 1 (recipients not in senders). Requires sorted input. grep -oP '<\K[^>]+' uses PCRE lookbehind — match < but don’t include it, capture until >.

Sort find results by modification time (newest first)

find discovers files but has no sort. Chain -printf with sort to order by mtime.

awk '{print $2}' truncates filenames with spaces — Familia Romana_ Lingva…​ becomes Familia. Always use the null-safe or sub() variants below for real data.
epoch sort — space-safe (production version)
# Sort by mtime, strip epoch prefix — handles spaces in filenames
find ~/Downloads -maxdepth 1 -name '*.pdf' -printf '%T@ %p\n' | sort -rn | awk '{sub(/^[^ ]+ /,""); print}'

sub(/[ ]+ /,"") removes everything up to and including the first space (the epoch). {print $2} would split on every space — fatal for Familia Romana_ Lingva Latina.

human-readable timestamps alongside
# ISO 8601 timestamps — readable and lexicographically sortable
find ~/Downloads -maxdepth 1 -name '*.pdf' -printf '%T+ %p\n' | sort -r | head -20

%T+ renders YYYY-MM-DD+HH:MM:SS — no epoch math needed, still sorts correctly as text.

null-safe — the bulletproof version
# Null-delimited: survives any filename (newlines, quotes, unicode)
find ~/Downloads -maxdepth 1 -name '*.pdf' -printf '%T@\t%p\0' | sort -zrn | awk -v RS='\0' -F'\t' '{print $2}'

-printf '%T@\t%p\0' — tab separates epoch from path, null terminates. sort -z sorts null-delimited records. awk -v RS='\0' -F'\t' reads null-terminated, splits on tab — $2 is now the full path regardless of spaces.

stat fallback — portable (BSD/macOS)
# GNU stat equivalent — works where -printf is unavailable
find ~/Downloads -maxdepth 1 -name '*latin*' -exec stat --format='%Y %n' {} + | sort -rn | awk '{sub(/^[^ ]+ /,""); print}'

-exec …​ {} + batches all files into one stat call (faster than \;). On macOS, use stat -f '%m %N' instead of --format='%Y %n'.

File intelligence — size, type, duplicates, age

Beyond finding files — interrogating them.

top 10 largest files in a directory tree
# Size in bytes (-printf %s), human-readable via numfmt
find ~/Downloads -type f -printf '%s\t%p\n' | sort -rn | head -10 | numfmt --to=iec --field=1

numfmt --to=iec --field=1 converts the first field from bytes to K/M/G. sort -rn on raw bytes is exact — ls -lhS rounds and sometimes mis-sorts.

find duplicates by size (fast pre-filter before checksumming)
# Files sharing a byte count — likely duplicates (confirm with md5sum)
find ~/Downloads -type f -printf '%s %p\n' | awk '{seen[$1]++; files[$1]=files[$1] "\n  " $0} END {for (s in seen) if (seen[s]>1) print files[s]}'
find duplicates by content — definitive
# md5sum only files with duplicate sizes (two-pass: fast then precise)
find ~/Downloads -type f -printf '%s\n' | sort | uniq -d | while read -r size; do
  find ~/Downloads -type f -size "${size}c" -exec md5sum {} +
done | sort | uniq -w32 -D

Two-pass: first find duplicate sizes (cheap), then md5sum only those (expensive). uniq -w32 -D compares first 32 chars (the hash) and prints all duplicates.

file type census — what’s actually in this directory?
# Count files by MIME type (not extension — extensions lie)
find ~/Downloads -type f -exec file --mime-type -b {} + | sort | uniq -c | sort -rn

file --mime-type -b reports actual content type. -b suppresses filename. A .pdf that’s really text/html is a failed download.

stale files — untouched for 30+ days
# Files not accessed in 30 days — candidates for cleanup
find ~/Downloads -maxdepth 1 -type f -atime +30 -printf '%A+ %s\t%p\n' | sort | numfmt --to=iec --field=2

-atime 30` = access time older than 30 days. `-printf '%A' shows last access. Useful for Downloads cleanup without deleting something you just renamed.

disk usage by subdirectory — sorted
# Which subdirectories consume the most space?
find . -maxdepth 1 -type d -exec du -sh {} + 2>/dev/null | sort -rh | head -20

Batch operations — rename, move, transform

rename all files — strip spaces, lowercase, normalize unicode
# Dry run — show what would change (remove echo to execute)
find ~/Downloads -maxdepth 1 -type f -name '* *' -print0 | while IFS= read -r -d '' f; do
  dir=$(dirname "$f")
  base=$(basename "$f" | tr ' ' '-' | tr '[:upper:]' '[:lower:]')
  echo mv "$f" "$dir/$base"
done

IFS= read -r -d '' — the holy trinity for null-safe filename reading. IFS= prevents whitespace trimming. -r prevents backslash interpretation. -d '' reads until null.

move files by extension into categorized subdirectories
# Sort Downloads chaos into folders by type
find ~/Downloads -maxdepth 1 -type f -print0 | while IFS= read -r -d '' f; do
  ext="${f##*.}"
  case "$ext" in
    pdf|epub)    dest="books" ;;
    jpg|png|svg) dest="images" ;;
    sh|py|rb)    dest="scripts" ;;
    *)           dest="other" ;;
  esac
  mkdir -p ~/Downloads/"$dest"
  echo mv "$f" ~/Downloads/"$dest"/
done

${f##.} — parameter expansion: strip longest match of . from front, leaving only the extension. No basename or awk needed.

batch convert epub → asciidoc (like your Cicero fetch script)
# Convert all epubs in a directory to asciidoc via pandoc
find . -name '*.epub' -type f -exec sh -c '
  for epub; do
    adoc="${epub%.epub}.adoc"
    pandoc -f epub -t asciidoc "$epub" -o "$adoc" \
      && printf "  → %s (%s lines)\n" "$adoc" "$(wc -l < "$adoc")" \
      || printf "  ✗ failed: %s\n" "$epub"
  done
' _ {} +

-exec sh -c '…​' _ {} + — batch mode. _ fills $0 (script name, discarded). All matched files become $1, $2, …​ iterated by for epub. One sh invocation, not one per file.

xargs power patterns

parallel processing — 4 cores
# Checksum all PDFs in parallel (4 processes)
find ~/Downloads -name '*.pdf' -print0 | xargs -0 -P4 md5sum

-P4 runs 4 md5sum processes simultaneously. -print0 | xargs -0 is the null-safe pipeline — no filename can break it.

batched execution — two arguments at a time
# Compare files pairwise with diff
find . -name '*.adoc' -print0 | xargs -0 -n2 diff --brief

-n2 feeds two arguments per invocation. Useful for pairwise comparisons, copy operations (-n2 with cp), or any command taking exactly two args.

placeholder — insert filename at specific position
# Backup every config file: cp <file> <file>.bak
find /etc -maxdepth 1 -name '*.conf' -print0 | xargs -0 -I{} cp {} {}.bak

-I{} replaces {} with each filename. Slower than + batching (one cp per file) but necessary when the filename must appear in a specific position.

Process substitution — diff without temp files

compare two directory listings
# What files exist in study-A but not study-B?
diff <(find data/d000/education/ciceron-study -type f -name '*.adoc' | sort) \
     <(find data/d000/education/latin-study -type f -name '*.adoc' | sort)

<(cmd) creates a file descriptor from command output. diff sees two "files" — no temp files created, no cleanup needed.

compare file counts across directories
# Side-by-side: file type census of two directories
paste <(find dir1 -type f -exec file --mime-type -b {} + | sort | uniq -c | sort -rn) \
      <(find dir2 -type f -exec file --mime-type -b {} + | sort | uniq -c | sort -rn)

awk, sed, jq

awk — field extraction

print second field (whitespace-delimited)
awk '{print $2}' file.txt
custom delimiter — colon-separated (like /etc/passwd)
awk -F: '{print $1, $3}' /etc/passwd
extract JSON code blocks from AsciiDoc
awk '/\[source,json\]/{getline; if ($0 ~ /^----/) {p=1; next}} p && /^----/{p=0; next} p' file.adoc
field extraction with printf formatting
awk '{printf "%-30s %s\n", $1, $2}' file.txt

sed — stream editing

in-place replacement with verify-before/after
# Before
awk 'NR==73' /etc/ssh/sshd_config
# Change
sed -i '73s/#GSSAPIAuthentication no/GSSAPIAuthentication yes/' /etc/ssh/sshd_config
# After
awk 'NR==73' /etc/ssh/sshd_config
extract line range
sed -n '10,20p' file.txt

sed — line-targeted replacement (verify-before / change / verify-after)

the full pattern: locate → validate → change → verify
# 1. LOCATE: find the line number
grep -n 'adoc-pdf' zsh/.zshrc

# 2. VALIDATE: read the exact line before changing
awk 'NR==1760' zsh/.zshrc

# 3. CHANGE: target by line number — only hits that line
sed -i '1760s/alias adoc-pdf=/alias build-adoc=/' zsh/.zshrc

# 4. VERIFY: confirm change AND check for collateral
grep -n 'build-adoc\|adoc-pdf' zsh/.zshrc

Without the line number prefix (1760s/), sed replaces every match in the file — a shotgun. With it, surgical. The line number comes from grep -n.

multi-line verify — check two specific lines at once
awk 'NR==1218 || NR==1760' zsh/.zshrc
range extraction — NR for surgical reads from large files
# grep found the error at line 44164 — read 50 lines of context
awk 'NR>=44160 && NR<=44210' session-dump.adoc

No head | tail chains. No sed -n '44160,44210p'. One awk, two numbers.

grep -oP with \K — value extraction from key-value logs

extract just the value after a key (Perl regex)
# ISE syslog — extract failure reasons
grep -oP 'FailureReason=\K[^,;]+' /var/log/syslog | sort | uniq -c | sort -rn

# ISE — extract MAC addresses
grep -oP 'Calling-Station-ID=\K[0-9A-Fa-f:.-]+' /var/log/syslog | sort -u

# ISE — extract NAS IPs
grep -oP 'NAS-IP-Address=\K[0-9.]+' /var/log/syslog | sort -u

# ISE — extract device names
grep -oP 'NetworkDeviceName=\K[^,;]+' /var/log/syslog | sort -u

\K resets the match start — everything before \K is required context but excluded from output. [^,;]+ captures until the next delimiter. Pipe to sort -u for unique, sort | uniq -c | sort -rn for counted frequency.

pattern: grep -oP 'KEY=\KVALUE_REGEX' | sort pipeline
# Generic form — works for any key=value log format
grep -oP 'FIELD_NAME=\K[^,;]+' logfile | sort | uniq -c | sort -rn | head -20

jq — JSON processing

extract nested fields
curl -s localhost:8080/stats | jq '.stats.total_files'
filter array by property
jq '.results[] | select(.category == "standards")' response.json
transform to TSV for spreadsheets
jq -r '.[] | [.title, .path] | @tsv' response.json | column -t -s $'\t'
GitHub API + jq — commit history by path
gh api "repos/EvanusModestus/domus-captures/commits?path=docs/&per_page=10" |
  jq -r '.[] | "\(.commit.author.date[:10]) \(.sha[:7]) \(.commit.message | split("\n")[0])"'

Shell Patterns

xargs — when the next command reads arguments, not stdin

Next command reads…​ Use

stdin (awk, grep, wc, sort)

pipe directly

arguments (stat, rm, cp, nvim, git add)

xargs

copy today’s files to backup — -I{} placeholder
mkdir -p /tmp/adoc-backup-$(date +%F) && \
  find . -name "*.adoc" -type f -newermt "$(date +%F)" | \
  xargs -I{} cp {} /tmp/adoc-backup-$(date +%F)/
parallel validation — -P4 runs 4 at a time
find .drafts -name "*.adoc" -type f | xargs -P4 -I{} asciidoctor -o /dev/null {}
null-delimited pipeline — safe for filenames with spaces
find . -name "*.adoc" -type f -print0 | xargs -0 wc -l

Process substitution — <(cmd) treats output as a file

compare tracker state: yesterday vs today
diff <(grep '|' partials/trackers/work/adhoc/carryover.adoc | head -20) \
     <(git show HEAD~1:partials/trackers/work/adhoc/carryover.adoc | grep '|' | head -20)
files on disk vs files in nav — drift detection
diff <(find docs/modules/ROOT/pages/projects/chla/mschapv2-migration -name "*.adoc" -type f | sort) \
     <(grep -oP 'mschapv2-migration/[^[]+\.adoc' docs/modules/ROOT/nav.adoc | sort)

Command substitution — embed output as arguments

open most recently modified file in nvim
nvim "$(find data/ -name '*.adoc' -type f -printf '%T@ %p\n' | sort -rn | awk 'NR==1{print $2}')"
line count across a project
wc -l $(find docs/modules/ROOT -path '*mschapv2*' -name '*.adoc' -type f)

Conditional execution — capture, test, act

open matching files only if they exist
files=$(find .drafts -name 'in*' -type f) && [ -n "$files" ] && nvim $files
open files that contain unchecked items
files=$(grep -rl '\[ \]' .drafts/*.adoc) && [ -n "$files" ] && nvim $files
guard with grep -q — only act if pattern matches
grep -q 'TODO\|FIXME\|\[ \]' "$file" && nvim "$file"

Pattern: $(capture)[ -n ] tests non-empty → && only proceeds if true. grep -q is the idempotent guard — run repeatedly, only opens when there’s work.

Decrypt and open — find .age, decrypt, nvim in one shot

files=$(find . -name "*tcp-clock*.age" -type f) && \
  [ -n "$files" ] && echo "$files" | xargs -I{} decrypt-file {} && \
  nvim $(echo "$files" | sed 's/\.age$//')

Pattern: find .age only (never tries plaintext), sed derives the decrypted path, guard prevents empty nvim. Change the glob to match any project.

tee_clean — color on screen, clean text in file

tee_clean() {
  tee >(sed 's/\x1b\[[0-9;]*m//g' > "$1")
}

# Color output on terminal, stripped in file
jq -C '.' data.json | tee_clean output.json
xq -C '.' data.xml | tee_clean output.json

# Wrap a whole block
{
  echo "=== Summary ==="
  jq -C '.[] | .name' data.json
} | tee_clean summary.txt

The >(cmd) is process substitution — tee writes to stdout AND to the subshell pipe. sed strips ANSI escape sequences (\x1b\[[0-9;]*m) before they hit the file.

Dependency check — verify toolchain in one shot

for cmd in asciidoctor asciidoctor-pdf pandoc rouge d2 mmdc age; do
  printf "%-20s %s\n" "$cmd" "$(command -v $cmd >/dev/null 2>&1 && echo 'OK' || echo 'MISSING')"
done

Pattern: command -v checks if binary exists on PATH. >/dev/null 2>&1 suppresses output — we only care about exit code. Swap the tool list for any project’s dependencies.

printf safety — dashes as data, not options

wrong — printf treats --- as invalid option
printf '---\n\n'
right — %s format string treats --- as data
printf '%s\n\n' '---'

Kill stuck SSH sessions

Find established SSH connections
lsof -i TCP -n -P | awk '/ssh.*ESTABLISHED/ {print $2, $9}'
Kill all stuck SSH sessions to a specific host
lsof -i TCP -n -P | awk '/ssh.*kvm-01.*ESTABLISHED/ {print $2}' | sort -u | xargs kill
Kill ALL stuck SSH sessions
lsof -i TCP -n -P | awk '/ssh.*ESTABLISHED/ {print $2}' | sort -u | xargs kill

lsof -i TCP -n -P lists all TCP connections. awk filters for SSH + ESTABLISHED, prints only the PID ($2). sort -u deduplicates (multiple file descriptors per process). xargs kill sends SIGTERM to each.

File Descriptors & Redirection

The three file descriptors

FD Name Purpose

0

stdin

input to the command

1

stdout

normal output (valid results)

2

stderr

error messages

Split stdout and stderr into separate files

find / -name "*.conf" 1>results.txt 2>errors.txt

Suppress errors — 2>/dev/null

find / -name "*.conf" 2>/dev/null

Merge stderr into stdout — 2>&1

command 2>&1 | grep "pattern"

This sends both stdout and stderr through the pipe. Without 2>&1, only stdout reaches grep — errors print to the terminal and bypass the pipeline.

Heredoc patterns

multi-line input to a command
cat <<'EOF'
Line 1
Line 2
EOF
heredoc commit messages (quotes prevent variable expansion)
git commit -m "$(cat <<'EOF'
feat: add new feature

Multi-line description here.
EOF
)"

API & curl/jq

domus-api — Documentation System REST API

start the API server
cd ~/atelier/_projects/personal/domus-api && uv run uvicorn domus_api.main:app --host 0.0.0.0 --port 8080
health check
curl -s localhost:8080/ | jq
full-text search
curl -s 'localhost:8080/search?q=mandiant' | jq
search — extract path, title, match count
curl -s 'localhost:8080/search?q=mandiant' | jq '.results[] | {path, title, match_count}'
list pages by category
curl -s 'localhost:8080/pages?category=standards' | jq
all antora.yml attributes
curl -s localhost:8080/attributes | jq

GitHub API

cross-repo search via GitHub API
gh search code "vault seal" --owner EvanusModestus --json repository,path,textMatches |
  jq '.[] | {repo: .repository.full_name, file: .path, match: .textMatches[].fragment}'
count .adoc files in a repo via API
gh api 'repos/EvanusModestus/domus-captures/git/trees/main?recursive=1' |
  jq '[.tree[] | select(.path | endswith(".adoc"))] | length'

Domus Workflows

Read content from terminal (meeting-ready)

today’s worklog
bat docs/modules/ROOT/pages/2026/04/WRKLOG-$(date +%Y-%m-%d).adoc
current priorities
bat docs/modules/ROOT/partials/trackers/work/priorities/current.adoc
carryover backlog
bat docs/modules/ROOT/partials/trackers/work/adhoc/carryover.adoc
any project summary
bat docs/modules/ROOT/partials/projects/mandiant-remediation/summary.adoc

Search and discovery

find all files related to a topic
grep -rl "MSCHAPv2" docs/modules/ROOT/ --include="*.adoc" | sort
search codex entries
grep -rn "pattern" docs/modules/ROOT/partials/codex/ --include="*.adoc" -B1 -A3
list all worklogs for a month
ls -1 docs/modules/ROOT/pages/2026/04/WRKLOG-*.adoc

Tracker aging — calculate days from origin

how many days since a carryover item started
echo $(( ($(date +%s) - $(date -d "2026-03-09" +%s)) / 86400 ))

Encrypted data access (d001)

view encrypted file without disk write
age --decrypt -i ~/.secrets/.metadata/keys/master.age.key \
  data/d001/projects/mandiant-remediation/findings-status-2026-04-16.adoc.age \
  | bat --language asciidoc
project encryption dashboard
for d in data/d001/projects/*/; do
  total=$(find "$d" -type f | wc -l)
  plain=$(find "$d" -type f ! -name '*.age' ! -name 'README.adoc' ! -name '.gitkeep' ! -name '*.py' | wc -l)
  printf "%-25s %s files  %s plaintext\n" "$(basename "$d")" "$total" "$plain"
done

d000 study builds

batch build all docs for a Quijote chapter range
for d in p1-cap-03{7,8,9}; do
  for f in data/d000/education/quijote-study/notas/$d/*.adoc; do
    d000 build "$d/$(basename "$f" .adoc)" html --variant light-cyan
  done
done
build a single study doc (use unique path fragment)
d000 build p1-cap-038/texto-anotado html --variant light-cyan
d000 build p1-cap-038/texto-anotado pdf --theme light-cyan
batch build PDFs for a chapter range
for d in p1-cap-03{7,8,9}; do
  for f in data/d000/education/quijote-study/notas/$d/*.adoc; do
    d000 build "$d/$(basename "$f" .adoc)" pdf --theme light-cyan
  done
done
open all rendered chapters at once
firefox data/d000/education/quijote-study/notas/p1-cap-03{7,8,9}/output/*.html &
firefox data/d000/education/quijote-study/notas/p1-cap-03{7,8,9}/output/*.pdf &
print chapter PDFs (requires CUPS configured)
lp data/d000/education/quijote-study/notas/p1-cap-03{7,8,9}/output/*.pdf
build LPL logic study (English / Spanish)
d000 build annotated-text pdf --theme light-cyan
d000 build lpl-study/notas/texto-anotado pdf --theme light-cyan
build Cicero study
d000 build de-oratore/libro-i/texto-anotado html --variant light-cyan

Available themes

PDF themes (--theme)
ls ~/atelier/_bibliotheca/domus-asciidoc-build/themes/pdf/ | sed 's/-theme\.yml//'
# base blue burgundy catppuccin creative dark don-quijote green
# learning light-cyan navy operations orange purple reference royal
HTML variants (--variant)
~/atelier/_bibliotheca/domus-asciidoc-build/docinfo/compose.sh --list
# light dark catppuccin royal light-cyan

ISE & Network Ops

ISE ERS API — endpoint CRUD

set credentials (session)
export ISE_HOST="{ise-ip}" ISE_USER="admin" ISE_PASS="$(gopass show -o ise/admin)"
list identity groups
curl -sk "https://$ISE_HOST:{ise-ers-port}/ers/config/identitygroup" \
  -H "Accept: application/json" -u "$ISE_USER:$ISE_PASS" | jq '.SearchResult.resources[].name'
check if endpoint exists by MAC
curl -sk "https://$ISE_HOST:{ise-ers-port}/ers/config/endpoint?filter=mac.EQ.AA:BB:CC:DD:EE:FF" \
  -H "Accept: application/json" -u "$ISE_USER:$ISE_PASS" | jq '.SearchResult.total'

Certificate inspection

view EAP-TLS client cert from local store
openssl x509 -in {cert-dir}/client.pem -text -noout | head -30
check cert expiry
openssl x509 -in {cert-dir}/client.pem -enddate -noout

Network diagnostics

check listening ports
ss -tlnp | grep -E ':{port-https}|:{port-ssh}|:{port-ldaps}'
test ISE connectivity
nc -zv {ise-ip} {ise-ers-port}
DNS resolution
dig {ise-hostname} +short

ISE eval rotation — backup & restore

backup from ISE CLI (when admin UI is license-locked)
# SSH to ISE
ssh admin@ise-02.inside.domusdigitalis.dev

# Verify NAS repo
show repository nas-01

# Get encryption key (on workstation)
dsource d000 dev/storage
echo $ISE_BACKUP_KEY

# Run backup
backup pre-rotation-2026-06 repository nas-01 ise-config encryption-key plain <KEY>
list backups on NAS
ssh admin@ise-02.inside.domusdigitalis.dev
show repository nas-01
restore to fresh ISE node
configure terminal
repository nas-01
  url nfs://10.50.1.70:/volume1/ise_backups
exit

restore <backup-filename> repository nas-01 encryption-key plain <KEY>

VyOS — VRRP & VLAN inspection

VRRP status and VIPs
show vrrp
show configuration commands | grep vrrp | grep 'address'
firewall zone membership
show configuration commands | grep 'firewall zone' | grep 'member'
DHCP leases and ARP
show dhcp server leases
show arp
full interface/VLAN map
show interfaces

CUPS printing — validation & setup

software validation
command -v lpstat && echo "CUPS present" || echo "CUPS not installed"
lpstat -r                                # scheduler running?
lpstat -p -d                             # printers + default
daemon lifecycle
sudo systemctl enable --now cups         # start + persist
printer discovery
lpinfo -v                                # available backends/URIs
lpinfo -m | grep -i <brand>             # available drivers
add printer and set default
sudo lpadmin -p <name> -v <uri> -m everywhere -E
lpoptions -d <name>
print
lp file.pdf                              # default printer
lp -d <name> -o sides=two-sided-long-edge file.pdf

PowerShell (from zsh)

All PowerShell commands run inside pwsh -NoLogo -Command '…​' from zsh. Running them bare fails — zsh interprets $, |, () as shell syntax.

Process management

top 5 processes by memory
pwsh -NoLogo -Command 'Get-Process | Sort-Object WorkingSet64 -Descending |
  Select-Object -First 5 ProcessName, Id,
    @{N="MB";E={[math]::Round($_.WorkingSet64/1MB)}} | Format-Table'
stop/start Teams
pwsh -NoLogo -Command 'Get-Process | Where-Object {$_.ProcessName -like "*teams*"} | Stop-Process'
pwsh -NoLogo -Command 'Start-Process "ms-teams"'

Export to JSON (pipe to jq)

always use -NoLogo when piping pwsh output to zsh tools
pwsh -NoLogo -Command 'Get-Process | Sort-Object WorkingSet64 -Descending |
  Select-Object -First 5 ProcessName, Id,
    @{N="MB";E={[math]::Round($_.WorkingSet64/1MB)}} | ConvertTo-Json' | jq '.'
Never pipe Format-Table into ConvertTo-Json — it produces layout metadata, not data. Select-Object first, then ConvertTo-Json.

Wi-Fi management (netsh)

force fresh network scan
netsh wlan disconnect interface="Wi-Fi"
netsh wlan show networks mode=bssid
netsh wlan connect name="CHLA-Remote" interface="Wi-Fi"

SSH from PowerShell

connect to homelab from Windows terminal
ssh evan@modestus-razer.inside.domusdigitalis.dev

WSL ↔ Windows — Cross-Environment Commands

From zsh (WSL) — control Windows
run any PowerShell command from zsh
pwsh -NoLogo -Command 'Get-Date'
run multi-line PowerShell from zsh (heredoc)
pwsh -NoLogo -Command "$(cat <<'PS'
$procs = Get-Process | Where-Object { $_.WorkingSet64 -gt 100MB }
$procs | Sort-Object WorkingSet64 -Descending |
  Select-Object ProcessName, Id, @{N="MB";E={[math]::Round($_.WorkingSet64/1MB)}} |
  Format-Table -AutoSize
PS
)"
open a file in Windows from WSL
# Open in default Windows app
wslview /mnt/c/Users/erosado/Documents/report.pdf

# Open Explorer to current WSL directory
explorer.exe .

# Open specific Windows path
explorer.exe 'C:\Users\erosado\Downloads'
copy WSL output to Windows clipboard
# Pipe anything to Windows clipboard
cat file.txt | clip.exe

# Copy a command's output
pwsh -NoLogo -Command 'Get-TransportRule | Format-List Name, State' | clip.exe
access Windows files from WSL
# Windows C: drive is at /mnt/c
ls /mnt/c/Users/erosado/Downloads/

# Copy from Windows to WSL
cp /mnt/c/Users/erosado/Downloads/report.pdf ~/atelier/

# Watch a Windows directory for new files
find /mnt/c/Users/erosado/Downloads -maxdepth 1 -mmin -5 -type f -printf '%T+ %p\n' | sort -r
From PowerShell — control WSL
run a bash command from PowerShell
wsl -e bash -c 'grep -rn "Ghost-Sender" ~/atelier/_bibliotheca/domus-captures/docs/'
run a specific WSL command and capture output
$result = wsl -e bash -c 'git -C ~/atelier/_bibliotheca/domus-captures log --oneline -5'
$result
Process Management — Windows Side
top processes by memory — formatted table
pwsh -NoLogo -Command '
Get-Process | Sort-Object WorkingSet64 -Descending |
  Select-Object -First 20 ProcessName, Id,
    @{N="MB";E={[math]::Round($_.WorkingSet64/1MB)}},
    @{N="CPU(s)";E={[math]::Round($_.CPU,1)}},
    @{N="Handles";E={$_.HandleCount}} |
  Format-Table -AutoSize'
find a specific process
pwsh -NoLogo -Command 'Get-Process | Where-Object { $_.ProcessName -like "*teams*" } |
  Select-Object ProcessName, Id, @{N="MB";E={[math]::Round($_.WorkingSet64/1MB)}} |
  Format-Table -AutoSize'
kill by name
pwsh -NoLogo -Command 'Stop-Process -Name "Teams" -Force -ErrorAction SilentlyContinue'
kill by PID
pwsh -NoLogo -Command 'Stop-Process -Id 12345 -Force'
what’s listening on a port (Windows equivalent of ss -tulnp)
pwsh -NoLogo -Command 'Get-NetTCPConnection -State Listen |
  Select-Object LocalAddress, LocalPort, OwningProcess,
    @{N="Process";E={(Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName}} |
  Sort-Object LocalPort | Format-Table -AutoSize'
specific port check
pwsh -NoLogo -Command 'Get-NetTCPConnection -LocalPort 8080 -ErrorAction SilentlyContinue |
  Select-Object LocalAddress, LocalPort, RemoteAddress, State,
    @{N="Process";E={(Get-Process -Id $_.OwningProcess).ProcessName}}'
Services — Windows Side
list running services
pwsh -NoLogo -Command 'Get-Service | Where-Object { $_.Status -eq "Running" } |
  Sort-Object DisplayName | Format-Table Name, DisplayName, Status -AutoSize'
check a specific service
pwsh -NoLogo -Command 'Get-Service -Name "WinRM" | Format-List Name, DisplayName, Status, StartType'
restart a service
Restart-Service -Name "WinRM" -Force
System Info — Quick Health from zsh
one-shot Windows system summary
pwsh -NoLogo -Command '
Write-Host "=== Windows System ===" -ForegroundColor Cyan
Write-Host "Hostname: $env:COMPUTERNAME"
Write-Host "User:     $env:USERNAME"
Write-Host "OS:       $((Get-CimInstance Win32_OperatingSystem).Caption)"
Write-Host "Uptime:   $((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime)"
Write-Host "RAM:      $([math]::Round((Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize/1MB))GB total, $([math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1MB))GB free"
Write-Host "CPU:      $((Get-CimInstance Win32_Processor).Name)"
Write-Host "Disk C:   $([math]::Round((Get-PSDrive C).Free/1GB))GB free of $([math]::Round(((Get-PSDrive C).Used + (Get-PSDrive C).Free)/1GB))GB"'
disk usage — all drives
pwsh -NoLogo -Command 'Get-PSDrive -PSProvider FileSystem |
  Select-Object Name, @{N="Used(GB)";E={[math]::Round($_.Used/1GB,1)}},
    @{N="Free(GB)";E={[math]::Round($_.Free/1GB,1)}},
    @{N="Total(GB)";E={[math]::Round(($_.Used+$_.Free)/1GB,1)}} |
  Format-Table -AutoSize'
Exchange Online — Connect from zsh
connect to Exchange Online (launches MFA prompt in Windows)
pwsh -NoLogo -Command 'Connect-ExchangeOnline -UserPrincipalName erosado@chla.usc.edu'
MFA prompt opens in the Windows browser. After auth, the session persists in the pwsh process. For multi-command sessions, start pwsh interactively instead of one-shot commands.
interactive PowerShell session from zsh (for Exchange, etc.)
pwsh -NoLogo
# Then inside pwsh:
# Connect-ExchangeOnline
# Get-TransportRule | Format-List Name, State
# exit
File Transfer Patterns
move files between WSL and Windows
# WSL → Windows Downloads
cp ~/atelier/_bibliotheca/domus-captures/output/report.pdf /mnt/c/Users/erosado/Downloads/

# Windows → WSL (glob)
cp /mnt/c/Users/erosado/Downloads/*.{png,pdf,jpg} ~/atelier/_staging/

# Bulk move with null safety
find /mnt/c/Users/erosado/Downloads -maxdepth 1 -name '*.pdf' -mmin -60 -print0 |
  xargs -0 -I{} cp {} ~/atelier/_staging/
watch Windows Downloads for new files (live)
inotifywait -m /mnt/c/Users/erosado/Downloads -e create -e moved_to |
  awk '{printf "%s  %s\n", strftime("%H:%M:%S"), $3}'
inotifywait requires inotify-tools. Install with sudo pacman -S inotify-tools if not present.

Security & Encryption

View encrypted files without writing to disk

pipe age decrypt to bat — nothing touches the filesystem
age --decrypt -i ~/.secrets/.metadata/keys/master.age.key \
  data/d001/projects/mandiant-remediation/findings-status-2026-04-16.adoc.age \
  | bat --language asciidoc --file-name "findings-status-2026-04-16.adoc"

Batch re-encrypt — brace expansion + loop

re-encrypt multiple project files
for f in data/d001/projects/mandiant-remediation/{findings-status,guest-acl-update,siem-report}-2026-04-16.adoc; do
  rm -f "${f}.age" && echo y | encrypt-file "$f"
done
Always rm -f the .age first. If you skip it, encrypt-file prompts about overwrite and may only delete the plaintext without re-encrypting.

Detect stale plaintext — files needing re-encryption

find plaintext newer than its .age counterpart
for f in data/d001/projects/*/*.adoc; do
  age="${f}.age"
  if [ -f "$f" ] && [ -f "$age" ]; then
    pt_mod=$(/usr/bin/stat -c'%Y' "$f")
    age_mod=$(/usr/bin/stat -c'%Y' "$age")
    [ "$pt_mod" -gt "$age_mod" ] && echo "STALE: $f"
  fi
done

Secure delete — shred for sensitive plaintext

shred -u data/d001/projects/mandiant-remediation/man-report.txt
On SSD/NVMe, shred is less effective (wear leveling), but better than rm which only removes the directory entry.

Pre-push audit — find all unencrypted project files

find data/d001/projects -type f ! -name '*.age' ! -name 'README.adoc' ! -name '.gitkeep' ! -name '*.py' | sort

System & Infrastructure

PipeWire audio validation

wpctl status                                    # PipeWire status
pactl list sinks short                          # list audio sinks
pw-play /usr/share/sounds/freedesktop/stereo/bell.oga  # test default sink
journalctl -b --grep='sof|cs35l56' --no-pager | tail -20  # kernel audio firmware
cat /proc/asound/cards                          # ALSA sound cards

gopass — personal document management

gopass-personal-docs    # interactive entry creation
gopass-query bills      # list recurring bills with totals
gopass-query storage    # list storage units with gate codes
gopass-query export bills  # export category to JSON

Makefile — daily workflow

make new-day      # create today's worklog + update attributes
make serve        # build + local server (port 8000)
make              # build only
make sync-nav     # sync worklog nav entries
make update-index # rebuild monthly index

KVM — VM & ISO management

list VMs on a KVM host
ssh kvm-01 "sudo virsh list --all"
ssh kvm-02 "sudo virsh list --all"
find ISE ISOs across KVM hosts (case-insensitive glob)
ssh kvm-01 "ls -lh /mnt/nas/isos/*[Ii][Ss][Ee]* /var/lib/libvirt/images/*[Ii][Ss][Ee]* /mnt/onboard-ssd/isos/*[Ii][Ss][Ee]* 2>/dev/null"
ssh kvm-02 "ls -lh /mnt/nas/isos/*[Ii][Ss][Ee]* /mnt/ssd/libvirt/images/*[Ii][Ss][Ee]* 2>/dev/null"
console into a VM
sudo virsh console <vm-name>             # Escape: Ctrl+]
check NAS mount on KVM host
ssh kvm-01 "mount | grep nas; ls /mnt/"

Per-project file dashboard

per-project summary — total files vs unencrypted plaintext
for d in data/d001/projects/*/; do
  total=$(find "$d" -type f | wc -l)
  plain=$(find "$d" -type f ! -name '*.age' ! -name 'README.adoc' ! -name '.gitkeep' ! -name '*.py' | wc -l)
  echo "$(basename "$d") | ${total} files | ${plain} plaintext"
done

USB-C / Thunderbolt Charging Diagnostics

Full evidence capture to file (one block, timestamped)
{
  echo "=== Power Supply ==="
  cat /sys/class/power_supply/*/status
  echo ""
  cat /sys/class/power_supply/*/type
  echo ""
  echo "=== UPower ==="
  upower -d | grep -E 'state|percentage|energy-rate|voltage'
  echo ""
  echo "=== dmesg (typec/thunderbolt/PD) ==="
  sudo dmesg | grep -iE 'typec|thunderbolt|ucsi|PD|power.delivery|charging' | tail -20
  echo ""
  echo "=== Pacman log (kernel/typec) ==="
  grep -iE 'thunderbolt|typec|ucsi|^.*upgraded linux ' /var/log/pacman.log | tail -20
} | tee /tmp/INC-$(date +%F)-usbc-charging.txt

Pattern: { } groups commands into a single stdout stream. tee writes to file AND displays on screen. Reusable for any multi-command evidence capture — change the commands inside, keep the structure.

pacman — package inspection

Source: 2026-06-25 — curl error 77 investigation, checking for package upgrades

# Check package version and install date (awk field filter)
pacman -Qi curl | awk '/^Version|^Install Date/'