WRKLOG-2026-06-21

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

Annual review — HR submission (.xlsx)

priority

trackers/work/priorities/current.adoc

P16g lid-wake black screen — apply CR fix

in progress

case-studies/changes/CR-2026-06-19

LilyPond Schradieck — verify notes against official PDF

pending

data/d000/education/music/violin/

Graphviz & D2 — advanced diagram authoring fundamentals

complete

partials/worklog/daily-notes/2026-06/2026-06-21/

Work

Annual Review — HR Submission (Priority)

Performance review submission. Final deliverable format: .xlsx.

Status
  • Locate HR template — duplicated from 2025 .xlsx

  • Draft content — self-eval-draft-2026.adoc (complete, cell-mapped)

  • Write into .xlsx — openpyxl, 22 cells updated

  • CISSP exam date confirmed (August 2026) — verify before submission

  • Review 3 [REVIEW] items in draft

  • Review with manager before submission

  • Submit in HR system

File Locations
File Path

2026 xlsx (submission)

data/d000/cursus/annual-review/chla/2026/my-annual-review-2026.xlsx

2025 xlsx (original, untouched)

data/d000/cursus/annual-review/chla/2025/my-annual-review.xlsx

Self-eval draft (content source)

data/d000/cursus/annual-review/chla/2025/self-eval-draft-2026.adoc

LaTeX portfolio PDF

data/d000/cursus/annual-review/chla/2025/main.pdf

Verify — Read Cells from Terminal
/tmp/xlsx-env/bin/python3 << 'PYEOF' | tee /tmp/annual-review-cells.txt
import openpyxl

wb = openpyxl.load_workbook('data/d000/cursus/annual-review/chla/2026/my-annual-review-2026.xlsx')
ws = wb['SelfEval']

cells = [
    ('D32',  'Name',            'value'),
    ('D36',  'Title',           'value'),
    ('J91',  'Job Skills',      'value'),
    ('J109', 'Outcomes',        'value'),
    ('J127', 'Service',         'value'),
    ('J162', 'Collaboration',   'value'),
    ('J170', 'Empathy',         'value'),
    ('J178', 'Accountability',  'value'),
    ('J187', 'Growth',          'value'),
    ('J196', 'Service Care',    'value'),
    ('C40',  'Contributions',   'len'),
    ('G40',  'Meaningful',      'len'),
    ('I49',  'Indiv Goal',      'len'),
    ('C102', 'Skills Comments', 'len'),
    ('C120', 'Outcomes Cmt',    'len'),
    ('C207', 'Values Comments', 'len'),
    ('C232', 'Leader Support',  'len'),
]

for cell, label, mode in cells:
    raw = ws[cell].value
    if mode == 'len':
        print(f"{cell:5s} {label:20s} {len(str(raw or ''))} chars")
    else:
        print(f"{cell:5s} {label:20s} {raw}")
PYEOF
Edit a Cell
CELL=C66
XLSX="data/d000/cursus/annual-review/chla/2026/my-annual-review-2026.xlsx"
TMP="/tmp/annual-review-${CELL}.txt"

# 1. Extract current value to temp file
/tmp/xlsx-env/bin/python3 << PYEOF
import openpyxl
wb = openpyxl.load_workbook('${XLSX}')
val = wb['SelfEval']['${CELL}'].value or ''
with open('${TMP}', 'w') as f:
    f.write(val)
print(f"BEFORE [${CELL}] ({len(val)} chars)")
PYEOF

# 2. Edit in your editor
${EDITOR:-nvim} "$TMP"

# 3. Write back and verify
/tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/annual-review-edit-${CELL}.txt
import openpyxl

with open('${TMP}') as f:
    new_val = f.read()

wb = openpyxl.load_workbook('${XLSX}')
ws = wb['SelfEval']
old_len = len(str(ws['${CELL}'].value or ''))
ws['${CELL}'] = new_val
wb.save('${XLSX}')

wb2 = openpyxl.load_workbook('${XLSX}')
final = wb2['SelfEval']['${CELL}'].value or ''
print(f"[${CELL}] {old_len} -> {len(final)} chars")
print(final)
PYEOF
Read Any Cell
CELL=C40
/tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/annual-review-${CELL}.txt
import openpyxl

wb = openpyxl.load_workbook('data/d000/cursus/annual-review/chla/2026/my-annual-review-2026.xlsx')
ws = wb['SelfEval']
cell = '${CELL}'
print(f"[{cell}] ({len(str(ws[cell].value or ''))} chars)")
print(ws[cell].value)
PYEOF
Rating Strategy (Do NOT Paste — Internal Reference)
Category Cell Rating

Job Skills & Knowledge

J91

Exemplary

Outcomes & Productivity

J109

Exemplary

Service Excellence

J127

Effective

Collaboration & Teamwork

J162

Effective

Empathy & Emotional Intelligence

J170

Effective

Accountability & Integrity

J178

Effective

Growth, Innovation

J187

Exemplary

Responsibility / Community

J196

Effective

3 Exemplary / 5 Effective — confident where evidence is strongest, humble elsewhere.

[REVIEW] Items Before Submission
  1. Team goal (C71) — "In Progress" for second year. Add any metrics work done this year.

  2. New individual goal (C66) — CISSP August 2026 date locked?

  3. Additional comments (C239) — Currently blank. Add anything personal for Sarah.

ISE API Enumeration + EAP-TEAP Policy Build

Plan to enumerate all 5 ISE APIs via shell scripts and build the EAP-TEAP authorization policy programmatically.

The 5 ISE APIs
API Protocol Purpose Partials

ERS

REST (HTTPS)

CRUD — endpoints, groups, authz profiles, dACLs, NADs, batch ops

data/shared/partials/ise-ers/ (17 files)

MnT

REST (HTTPS)

Read-only — active sessions, auth history, CoA (reauth/disconnect)

data/shared/partials/ise-mnt/ (10 files)

DataConnect

Oracle SQL (Python oracledb)

Direct DB — full session/endpoint tables, reporting, export

data/shared/partials/ise-dataconnect/ (12 files)

pxGrid

WebSocket/STOMP

Pub/sub — real-time session events, profiler data, registration

data/shared/partials/ise-pxgrid/ (7 files)

OpenAPI

REST (HTTPS)

Policy CRUD — policy sets, authz/authn rules, conditions (ISE 3.1+)

data/shared/partials/ise-openapi/ (3 files)

Which API for TEAP Policy?

The OpenAPI is the one that creates policy sets and authorization rules. ERS handles endpoints/groups/profiles but NOT policy sets.

Task API

Create Allowed Protocols list with EAP-TEAP enabled

OpenAPI (/api/v1/policy/network-access/policy-set)

Create Authorization Policy rule matching TEAP

OpenAPI (/api/v1/policy/network-access/authorization/rules)

Verify endpoint after auth

ERS (/ers/config/endpoint)

Check active session

MnT (/admin/API/mnt/Session/ActiveList)

Query auth logs for TEAP events

DataConnect (SQL against RADIUS_AUTHENTICATION)

Real-time session monitoring

pxGrid (session topic subscription)

Shell Script Approach

Each API gets its own script. Common patterns extracted to a shared library.

Directory structure
data/d000/infra/scripts/ise/
├── lib/
│   └── ise-common.sh          # Base URL, auth, error handling, jq patterns
├── ers/
│   ├── list-endpoints.sh
│   ├── list-groups.sh
│   ├── list-authz-profiles.sh
│   ├── list-dacls.sh
│   ├── list-nads.sh
│   └── create-endpoint.sh
├── mnt/
│   ├── active-sessions.sh
│   ├── auth-history.sh
│   └── coa-reauth.sh
├── openapi/
│   ├── list-policy-sets.sh
│   ├── list-authz-rules.sh
│   ├── list-allowed-protocols.sh
│   └── create-teap-policy.sh   # ← the goal
└── dc/
    └── query.sh                # wraps dc-run-sql
Shared library pattern (lib/ise-common.sh)
#!/bin/bash
# Source this in every script: source "$(dirname "$0")/../lib/ise-common.sh"

ISE_HOST="${ISE_HOST:-ise-01.inside.domusdigitalis.dev}"
ISE_USER="${ISE_USER:-admin}"
ISE_PASS="${ISE_PASS:-$(gopass show ise/admin 2>/dev/null)}"

ise_get() {
  local endpoint="$1"
  curl -sk -u "${ISE_USER}:${ISE_PASS}" \
    -H "Accept: application/json" \
    "https://${ISE_HOST}:9060$\{endpoint}"
}

ise_post() {
  local endpoint="$1" payload="$2"
  curl -sk -u "${ISE_USER}:${ISE_PASS}" \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -X POST -d "@${payload}" \
    "https://${ISE_HOST}:9060$\{endpoint}"
}

ise_openapi_get() {
  local endpoint="$1"
  curl -sk -u "${ISE_USER}:${ISE_PASS}" \
    -H "Accept: application/json" \
    "https://${ISE_HOST}$\{endpoint}"
}
Immediate Next Steps
  1. Enumerate current state — list existing policy sets, allowed protocols, authz rules

  2. Identify gaps — does an Allowed Protocols list with TEAP exist? What authz rules apply to Linux endpoints?

  3. Build TEAP policy — create via OpenAPI if missing

  4. Testwpa_supplicant on wired interface against ise-01

Quick enumeration (run now to see current state)
# List all policy sets (OpenAPI)
curl -sk -u admin:"$(gopass show ise/admin)" \
  -H "Accept: application/json" \
  "https://ise-01.inside.domusdigitalis.dev/api/v1/policy/network-access/policy-set" \
  | jq '.response[].name'

# List allowed protocols (ERS)
curl -sk -u admin:"$(gopass show ise/admin)" \
  -H "Accept: application/json" \
  "https://ise-01.inside.domusdigitalis.dev:9060/ers/config/allowedprotocols" \
  | jq '.SearchResult.resources[].name'

# Check if TEAP is in any allowed protocols list
curl -sk -u admin:"$(gopass show ise/admin)" \
  -H "Accept: application/json" \
  "https://ise-01.inside.domusdigitalis.dev:9060/ers/config/allowedprotocols" \
  | jq '.SearchResult.resources[].id' -r \
  | while read id; do
      curl -sk -u admin:"$(gopass show ise/admin)" \
        -H "Accept: application/json" \
        "https://ise-01.inside.domusdigitalis.dev:9060/ers/config/allowedprotocols/$id" \
        | jq '{name: .AllowedProtocols.name, teap: .AllowedProtocols.teap}'
    done
Reference Partials

Existing validated patterns — read before writing new scripts:

  • data/shared/partials/ise-ers/prereqs.adoc — auth setup, base URLs, headers

  • data/shared/partials/ise-ers/helpers.adoc — curl wrappers, jq patterns

  • data/shared/partials/ise-openapi/policy.adoc — policy CRUD

  • data/shared/partials/ise-openapi/endpoints.adoc — OpenAPI endpoint list

ISE API Enumeration — First Run

Activated the ISE API script library and ran first enumeration against ise-01.

Issue: 401 Unauthorized

First attempt returned HTTP 401 — the API account was disabled in ISE.

Diagnosis
# Check HTTP status without body
curl -sS -o /dev/null -w '%\{http_code}' --cacert "$\{ISE_CA_CERT}" \
  -u "$\{ISE_API_USER}:$\{ISE_API_PASS}" \
  -H "Accept: application/json" \
  "https://$\{ISE_PAN_FQDN}:$\{ISE_ERS_PORT}/ers/config/endpointgroup"
# 401 = bad creds or account disabled
# 200 = working
# 403 = ERS API not enabled
# 404 = wrong endpoint path
Fix

Re-enabled the API account in ISE GUI → Administration → System → Admin Access → Administrators. Re-ran the curl check — returned 200.

Script Library Debugging

Three issues discovered and fixed during activation:

Issue 1: Double path in ERS scripts

The ers() function prepends /ers/config but the scripts also included it:

# WRONG — ers() adds /ers/config, script adds it again
ers "/ers/config/endpointgroup"
# Result: https://ise:9060/ers/config/ers/config/endpointgroup

# RIGHT — pass resource name only
ers "/endpointgroup"
Issue 2: set -euo pipefail kills interactive shell

Sourcing ise-common.sh into zsh with set -e causes the shell to exit on the first non-zero return. Fixed with a guard:

# Only strict mode when running as a script, not when sourced
if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]] 2>/dev/null; then
  set -euo pipefail
fi
Issue 3: ${!var} bash indirect expansion fails in zsh

The variable validation used bash-only ${!var} syntax. Fixed with POSIX-compatible ${VAR:?message}:

# WRONG — bash only
for var in ISE_PAN_FQDN ISE_API_USER; do
  [[ -z "${!var:-}" ]] && echo "missing $var"
done

# RIGHT — works in bash and zsh
: "$\{ISE_PAN_FQDN:?ERROR: not set. Run: dsource d000 dev/network/ise}"
Successful Enumeration

After fixes, the library works from any directory:

# Load creds
dsource d000 dev/network/ise

# Source the library (for interactive use)
source $(find ~/atelier -name 'ise-common.sh' -path '*/ise-api-reference/*')

# Test
ers "/endpointgroup" | head -c 80
# {"SearchResult":{"total":46,"resources":[{"id":"ffa...

# Full enumeration to file
bash $(find ~/atelier -name 'enumerate-all.sh' -path '*/ers/*') /tmp/ise-ers-dump.json
bash $(find ~/atelier -name 'enumerate-all.sh' -path '*/openapi/*') /tmp/ise-openapi-dump.json

# Inspect
jq '.endpointGroups[].name' /tmp/ise-ers-dump.json
jq '.[].policySet' /tmp/ise-openapi-dump.json
Script Library Location
data/d000/infra/ise-api-reference/scripts/
├── lib/
│   └── ise-common.sh              ← dsource vars, curl wrappers, jq helpers
├── ers/
│   ├── enumerate-all.sh           ← full ERS config dump
│   ├── list-endpoints.sh          ← with filter support
│   ├── list-groups.sh             ← names/table/full
│   ├── list-authz-profiles.sh     ← detail mode
│   ├── list-dacls.sh              ← ACL content view
│   ├── list-nads.sh               ← name + IP table
│   └── list-allowed-protocols.sh  ← TEAP check mode
├── mnt/
│   ├── active-sessions.sh         ← count/table/full
│   ├── auth-history.sh            ← by MAC + time window
│   └── coa-reauth.sh              ← force re-auth
├── openapi/
│   ├── enumerate-all.sh           ← full policy hierarchy
│   ├── list-policy-sets.sh        ← names/table/full
│   ├── list-authn-rules.sh        ← per policy set
│   └── list-authz-rules.sh        ← per policy set
├── dataconnect/                   ← uses dc-run-sql
└── pxgrid/                        ← WebSocket (different pattern)
Usage Pattern (run from anywhere)
# Always: load creds first
dsource d000 dev/network/ise

# Run scripts with find — no cd needed
bash $(find ~/atelier -name 'list-groups.sh' -path '*/ers/*') names
bash $(find ~/atelier -name 'list-allowed-protocols.sh' -path '*/ers/*') teap
bash $(find ~/atelier -name 'active-sessions.sh' -path '*/mnt/*') table
bash $(find ~/atelier -name 'list-policy-sets.sh' -path '*/openapi/*') table

# Interactive: source the library, call functions directly
source $(find ~/atelier -name 'ise-common.sh' -path '*/ise-api-reference/*')
ers "/endpointgroup" | jq '.SearchResult.total'
openapi "/api/v1/policy/network-access/policy-set" | jq '.response[].name'
mnt "/admin/API/mnt/Session/ActiveCount" | mnt_json
Full Enumeration Workflow — Load → Acquire → Extract

The complete flow from credential loading to structured output:

Step 1: Load credentials
dsource d000 dev/network/ise
Step 2: Run ERS enumeration (all resources, paginated)
bash $(find ~/atelier -name 'enumerate-all.sh' -path '*/ers/*') /tmp/ise-ers-dump.json
Step 3: Run OpenAPI enumeration (policy hierarchy)
bash $(find ~/atelier -name 'enumerate-all.sh' -path '*/openapi/*') /tmp/ise-openapi-dump.json
Step 4: Extract with jq

ERS resources:

# All endpoint group names (46)
jq -r '.endpointGroups[].name' /tmp/ise-ers-dump.json

# All authorization profile names (32)
jq -r '.authorizationProfiles[].name' /tmp/ise-ers-dump.json

# All dACL names (30)
jq -r '.downloadableAcls[].name' /tmp/ise-ers-dump.json

# NADs — name + description (3)
jq -r '.networkDevices[] | [.name, .description] | @tsv' /tmp/ise-ers-dump.json

# Allowed protocol list names (5)
jq -r '.allowedProtocols[].name' /tmp/ise-ers-dump.json

OpenAPI policy hierarchy:

# Policy set names
jq -r '.[].policySet' /tmp/ise-openapi-dump.json

# Authentication rules per policy set
jq -r '.[] | "=== \(.policySet) ===", (.authenticationRules[]?.rule.name // empty)' \
  /tmp/ise-openapi-dump.json

# Authorization rules per policy set
jq -r '.[] | "=== \(.policySet) ===", (.authorizationRules[]?.rule.name // empty)' \
  /tmp/ise-openapi-dump.json

# Full authz table: policy set → rule → state → profile
jq -r '.[] | .policySet as $ps | .authorizationRules[]? |
  [$ps, .rule.name, .rule.state, (.profile // ["none"] | join(","))] | @tsv' \
  /tmp/ise-openapi-dump.json | column -t

# Identity sources per policy set
jq -r '.[] | .policySet as $ps | .authenticationRules[]? |
  [$ps, .rule.name, .identitySourceName] | @tsv' \
  /tmp/ise-openapi-dump.json | sort -u | column -t
ERS Enumeration Results (2026-06-21)
Resource Count

Endpoint Groups

46

Authorization Profiles

32

Downloadable ACLs

30

Network Devices

3

Allowed Protocols

5

Endpoints

97

OpenAPI Policy Hierarchy (2026-06-21)
Policy Sets
Domus_8021X
Domus_MAB
Default
Authentication Rules
=== Domus_8021X ===
EAP_TLS_CertAuth    → AD_Cert_Profile
Default              → All_User_ID_Stores

=== Domus_MAB ===
iPSK_Lookup          → ipsk-mgr-01
Default              → Internal Endpoints

=== Default ===
MAB                  → Internal Endpoints
Dot1X                → All_User_ID_Stores
Default              → All_User_ID_Stores
Authorization Rules (Domus_8021X)
Domus_Cert_Admin_P16g        enabled   → Domus_Admin_Profile
Domus_Cert_Admins            enabled   → Domus_Admin_Profile
Domus_Cert_Users_Exact       enabled   → Domus_Secure_Profile
Domus_Cert_Research_Exact    enabled   → Domus_Research_Profile
Domus_BYOD_Devices           enabled   → Domus_Secure_Profile
Domus_Cert_Infra_Specific    enabled   → Domus_Admin_Profile
Domus_Cert_Research_Specific enabled   → Domus_Research_Profile
Domus_TEAP_Machine_Only      enabled   → Domus_Secure_Profile
Domus_TEAP_Chaining          enabled   → Domus_Secure_Profile
Domus_Cert_Users             enabled   → Domus_Secure_Profile
Domus_Cert_Research          enabled   → Domus_Research_Profile
EAP_TLS_Permit               enabled   → PermitAccess
Default                      enabled   → DenyAccess
Authorization Rules (Domus_MAB)
Domus_Trusted_APs            enabled   → Domus_Secure_Profile
Domus_MGMT_Devices           enabled   → Domus_Admin_Profile
Domus_IoT_Wireless           enabled   → Domus-IoT-iPSK
Domus_Research_Onboard       enabled   → Research_Onboard
Domus_Storage                enabled   → Domus_Admin_Profile
Domus_Printers               enabled   → Domus-IoT-iPSK
Default                      enabled   → DenyAccess
Authorization Rules (Default — mostly disabled)
Wireless Block List Default  enabled   → Block_Wireless_Access
Profiled Cisco IP Phones     enabled   → Cisco_IP_Phones
Profiled Non Cisco IP Phones enabled   → Non_Cisco_IP_Phones
Basic_Authenticated_Access   enabled   → Trusted_LAN_VLAN10
(6 rules disabled — compliance, onboarding, guest)
Key Finding: TEAP Already Configured
  • Allowed Protocols: Domus_TEAP_TLS_TTLS exists in the protocol list

  • Authorization Rules: Domus_TEAP_Machine_Only and Domus_TEAP_Chaining are enabled in Domus_8021X

  • Both map to Domus_Secure_Profile

  • Next step: Test wpa_supplicant EAP-TEAP on wired interface against ise-01

Critical Discovery: MnT API Returns XML Only

The ISE MnT (Monitoring) API returns XML only. Sending Accept: application/json returns HTTP 406 (Not Acceptable) with an empty body — no error message, just silence.

Diagnosis
# This returns HTTP 406 — empty body, no error
curl -s --cacert "$\{ISE_CA_CERT}" \
  -u "$\{ISE_API_USER}:$\{ISE_API_PASS}" \
  -H "Accept: application/json" \
  -w "\nHTTP_CODE: %\{http_code}\n" \
  "https://$\{ISE_PAN_FQDN}/admin/API/mnt/Session/ActiveCount"
# HTTP_CODE: 406

# This works — XML response
curl -s --cacert "$\{ISE_CA_CERT}" \
  -u "$\{ISE_API_USER}:$\{ISE_API_PASS}" \
  -H "Accept: application/xml" \
  -w "\nHTTP_CODE: %\{http_code}\n" \
  "https://$\{ISE_PAN_FQDN}/admin/API/mnt/Session/ActiveCount"
# HTTP_CODE: 200
Fix — convert XML→JSON with xq (ships with Python yq)
# xq converts XML to JSON (part of the python-yq package)
curl -s --cacert "$\{ISE_CA_CERT}" \
  -u "$\{ISE_API_USER}:$\{ISE_API_PASS}" \
  -H "Accept: application/xml" \
  "https://$\{ISE_PAN_FQDN}/admin/API/mnt/Session/ActiveCount" | xq '.'

# Result:
# {
#   "sessionCount": {
#     "count": "4"
#   }
# }
Table 1. ISE API content-type matrix
API Accept Header Conversion

ERS (port 9060)

application/json

Native JSON — use jq directly

OpenAPI (port 443)

application/json

Native JSON — use jq directly

MnT (port 443)

application/xml

XML → JSON via xq '.'

The mnt() function in ise-common.sh now sends Accept: application/xml. The mnt_json() helper pipes through xq '.' for JSON conversion.

HTTP Status Quick Reference
Code Meaning

200

Success

401

Bad credentials or account disabled

403

ERS API not enabled (Administration → Settings → ERS)

404

Wrong endpoint path

406

Not Acceptable — MnT rejects application/json (use application/xml)

500

ISE internal error (check ISE admin logs)

dsec — WLC Credential Segmentation

Segmented Cisco 9800 WLC credentials from the monolithic d000 dev/network tier into d000 dev/network/wlc. Same pattern as FMC segmentation (2026-06-20).

Segmentation Process
Step 1: Show keys in the parent tier (no values exposed)
dsec show d000 dev/network | awk -F= '{print $1}'
Step 2: Extract WLC vars WITH values to temp file
dsec show d000 dev/network | grep -i WLC > /tmp/wlc-env.tmp
Step 3: Verify extraction (keys only — never cat the values)
wc -l /tmp/wlc-env.tmp
awk -F= '{print $1}' /tmp/wlc-env.tmp
Output
15 /tmp/wlc-env.tmp

# CISCO WLC - Catalyst 9800
WLC_01_HOST
WLC_01_IP
WLC_01_USER
WLC_01_PASS
WLC_01_API_USER
WLC_01_API_PASS
WLC_01_API_TOKEN
WLC_HOST
WLC_IP
WLC_USER
WLC_PASS
WLC_API_USER
WLC_API_PASS
WLC_API_TOKEN
Step 4: Create the new tier
dsec add d000 dev/network/wlc /tmp/wlc-env.tmp
# ✓ Added dev/network/wlc to d000
# Shred source file? [y/N] y
# → Shredding (10 passes)...
# ✓ Source file destroyed
Step 5: Verify (keys only)
dsec show d000 dev/network/wlc | awk -F= '{print $1}'
Step 6: Load for use
dsource d000 dev/network/wlc
Step 7: Commit the encrypted tier to secrets repo
# The .age file lands in the secrets repo
git add environments/domains/d000/dev/network/wlc.env.age
git commit -m "segmented d000 wlc vars in dev"
git push
Current Tier Map

After ISE (already segmented) and WLC segmentation:

Tier Content Vars

d000 dev/network

Monolithic — still contains ISE, VyOS, switches, KVM, Ansible

~100+

d000 dev/network/ise

ISE only — ERS, MnT, DataConnect, pxGrid, OpenAPI, mTLS, backup

45

d000 dev/network/fmc

FMC only — segmented 2026-06-20

8

d000 dev/network/wlc

WLC only — Catalyst 9800, device + active target + API creds

14

Remaining Segmentation (TODO)

The parent d000 dev/network still contains everything. Each vendor group should get its own tier for blast radius isolation:

  • d000 dev/network/vyos — VyOS VRRP pair (API keys)

  • d000 dev/network/switches — 3560-CX + 9300 (RESTCONF creds)

  • d000 dev/network/kvm — KVM hypervisor SSH

  • Clean parent tier — remove segmented vars, keep only global settings (DOMAIN_ID, DOMAIN_SUFFIX, CA_CERT_PATH)

The Pattern (reusable for any vendor)
# 1. Extract
dsec show d000 dev/network | grep -i VENDOR > /tmp/vendor-env.tmp

# 2. Verify keys (never cat values)
awk -F= '{print $1}' /tmp/vendor-env.tmp

# 3. Create tier (dsec encrypts + shreds source)
dsec add d000 dev/network/vendor /tmp/vendor-env.tmp

# 4. Verify
dsec show d000 dev/network/vendor | awk -F= '{print $1}'

# 5. Load
dsource d000 dev/network/vendor

# 6. Commit encrypted .age
git -C ~/.secrets add environments/domains/d000/dev/network/vendor.env.age
git -C ~/.secrets commit -m "segmented d000 vendor vars"
git -C ~/.secrets push
SSH Key Issue

First git push to secrets repo failed with Permission denied (publickey) — wrong SSH key offered. Second attempt succeeded after the correct key was loaded. The SSH agent passphrase prompt for id_ed25519_github appeared and push completed.

iPSK Manager — SQL Reconnaissance Plan

Connected to ipsk-mgr-01 via SSH. Database is MariaDB (not MySQL — confirmed via gopass entries showing mariadb-root).

Credential Paths (gopass)
v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/mariadb-root   ← DB root
v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/ipsk-ise       ← ISE ODBC user
v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/ui             ← Web portal admin
v3/domains/d000/services/ipsk-mgr/mariadb-readonly           ← Read-only user
Connect
ssh ipsk-mgr-01
mysql -u ipsk -p ipsk
# Password: gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/mariadb-root
Essential Queries
Count endpoints
SELECT COUNT(*) FROM endpoints;
All endpoints with status
SELECT macAddress, fullName, pskValue, vlan, accountEnabled, lastAccessed
FROM endpoints ORDER BY lastAccessed DESC;
Trace endpoint → group → template → VLAN
SELECT e.macAddress, e.vlan AS endpoint_vlan, e.pskValue,
  eg.groupName, at.authzPolicyName, at.vlan AS template_vlan
FROM endpoints e
JOIN endpointAssociations ea ON e.id = ea.endpointId
JOIN endpointGroups eg ON ea.epGroupId = eg.id
JOIN authorizationTemplates at ON eg.authzTemplateId = at.id;
Test stored procedures (what ISE calls via ODBC)
CALL iPSK_FetchPasswordForMAC('70:15:FB:F8:47:EC');
CALL iPSK_AttributeFetch('70:15:FB:F8:47:EC');
CALL iPSK_MACLookup('70:15:FB:F8:47:EC');
CALL iPSK_MACLookupNonExpired('70:15:FB:F8:47:EC');
CALL iPSK_FetchGroups('70:15:FB:F8:47:EC');
List all stored procedures
SHOW PROCEDURE STATUS WHERE Db = 'ipsk';
List configured SSIDs
SELECT id, ssidName, description FROM wirelessNetworks;
Groups with templates
SELECT eg.id, eg.groupName, eg.description, at.authzPolicyName, at.vlan
FROM endpointGroups eg
JOIN authorizationTemplates at ON eg.authzTemplateId = at.id;
Existing Documentation

Comprehensive iPSK SQL reference already exists in Principia:

  • 02_Assets/PRJ-ISE-CHLA/authentication/iPSK-Manager/ — CHLA enterprise deployment (scripts, configs, diagrams, vendor docs)

  • 02_Assets/PRJ-ISE-HOME/authentication/2025-ISE-153-iPSK-Manager-Home-Lab/ — Home lab deployment (14 deployment guide versions)

  • 02_Assets/PRJ-ISE-IPSK-CHLA-ANTORA/ — CHLA Antora docs (HA, backup, security hardening, troubleshooting)

  • 02_Assets/PRJ-ISE-IPSK-HOME-ANTORA/ — Home lab Antora docs (same structure)

  • 02_Assets/TAB-IDEAS/LEARNING/TIL-2025-10-24-sql-ipsk-database-ops.md — SQL operations reference

WLC Integration

The WLC Domus-IoT SSID uses iPSK with MAC filtering through ISE ODBC. The FallbackPSK2026 in the WLAN config is only used if ISE/iPSK is unreachable — normal auth goes through the stored procedures above.

iPSK Manager Installation Reference (donotdeletefile)

The donotdeletefile in gopass contains the iPSK Manager installation metadata — do not delete this data.

Table 2. Key installation values
Field Value

Organization SID

S-1-9-1772958638-1772958638-1

Encryption Key

Stored in gopass (encrypts sensitive MySQL data at rest)

DB Host

localhost

DB User (app)

ipsk

DB Name

ipsk

ISE ODBC User

ipsk-ise

ISE ODBC Host

ipsk-mgr-01.inside.domusdigitalis.dev:3306

ISE ODBC Stored Procedure Mapping

These are configured in ISE under Administration → Identity Management → External Identity Sources → ODBC → Stored Procedures tab.

Table 3. Required stored procedures (5)
ISE Field Stored Procedure Name

Plain Text Password Authentication

iPSK_AuthMACPlain

Plain Text Password Fetching

iPSK_FetchPasswordForMAC

Check Username or Machine Exists

iPSK_MACLookup

Fetch Groups

iPSK_FetchGroups

Fetch Attributes

iPSK_AttributeFetch

Table 4. Optional — Non-Expired variants (return only active, non-expired endpoints)
Standard Procedure Non-Expired Variant

iPSK_AuthMACPlain

iPSK_AuthMACPlainNonExpired

iPSK_FetchPasswordForMAC

iPSK_FetchPasswordForMACNonExpired

iPSK_MACLookup

iPSK_MACLookupNonExpired

Test each procedure from the CLI
ssh ipsk-mgr-01
mysql -u ipsk -p ipsk

-- Test all 5 required procedures with a known MAC
CALL iPSK_AuthMACPlain('70:15:FB:F8:47:EC');
CALL iPSK_FetchPasswordForMAC('70:15:FB:F8:47:EC');
CALL iPSK_MACLookup('70:15:FB:F8:47:EC');
CALL iPSK_FetchGroups('70:15:FB:F8:47:EC');
CALL iPSK_AttributeFetch('70:15:FB:F8:47:EC');

-- Test non-expired variants
CALL iPSK_AuthMACPlainNonExpired('70:15:FB:F8:47:EC');
CALL iPSK_FetchPasswordForMACNonExpired('70:15:FB:F8:47:EC');
CALL iPSK_MACLookupNonExpired('70:15:FB:F8:47:EC');
Verify ISE ODBC connection from ISE GUI
  1. Administration → Identity Management → External Identity Sources → ODBC

  2. Click iPSKManager (or your ODBC source name)

  3. Click Test Connection — must show success

  4. Go to Stored Procedures tab — verify all 5 procedures are mapped

  5. Click Test next to each procedure — enter a known MAC to verify response

ODBC Source Name Warning
The ODBC source name in ISE must be iPSKManager (no hyphens). ISE 2.6+ has a bug where hyphens in ODBC source names cause literal string return instead of value substitution. Never use iPSK-Manager.
Next Steps
  • SSH to ipsk-mgr-01, run the queries above, capture output

  • Verify stored procedures return correct PSK/VLAN for known devices

  • Compare iPSK endpoint count against ISE ERS endpoint count (97)

  • Document any discrepancies between iPSK DB and ISE state

iPSK Device Troubleshooting

Troubleshooting workflow for IoT devices on the iPSK (Identity Pre-Shared Key) network. ISE handles MAB authentication, iPSK Manager holds the identity — you need both.

Troubleshooting Chain
Step Source What It Shows Tool

1. Is it connected?

ISE MnT

Active session, NAS, WLAN

active-sessions.sh

2. What group?

ISE ERS

Endpoint group, profile

list-endpoints.sh

3. Who owns it?

iPSK Manager (MariaDB)

PSK, VLAN, user info

iPSK_FetchPasswordForMAC

4. What attributes?

iPSK Manager (MariaDB)

VLAN assignment, tags

iPSK_AttributeFetch

5. Auth history?

ISE MnT

Pass/fail, method, timestamp

auth-history.sh

6. WLC view?

WLC RESTCONF

AP, WLAN, signal, client state

get-clients.sh

Step 1 — Check ISE Active Sessions
dsource d000 dev/network/ise

# All active sessions
bash $(find ~/atelier -name 'active-sessions.sh' -path '*/ise*') \
  | tee /tmp/ise-active-sessions.json | jq '.activeList."@noOfActiveSession"'

# Filter by MAC
jq '.activeList.activeSession[]? | select(.calling_station_id | ascii_upcase | contains("AA:BB:CC"))' \
  /tmp/ise-active-sessions.json
Step 2 — Check ISE Endpoint Group
# Search by MAC in ERS
bash $(find ~/atelier -name 'list-endpoints.sh' -path '*/ers/*') \
  | tee /tmp/ise-endpoints.json | jq '.SearchResult.resources[] | select(.name | ascii_upcase | contains("AABB"))'
Step 3 — Query iPSK Manager (MariaDB)

Credentials: gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01

Username: ipsk

Fetch PSK for a MAC
ssh ipsk-mgr-01 "mysql -u ipsk -p ipsk -e \"CALL iPSK_FetchPasswordForMAC('AA:BB:CC:DD:EE:FF')\""
Fetch VLAN/Attributes for a MAC
ssh ipsk-mgr-01 "mysql -u ipsk -p ipsk -e \"CALL iPSK_AttributeFetch('AA:BB:CC:DD:EE:FF', @v); SELECT @v\""
List all registered devices (careful — may be large)
ssh ipsk-mgr-01 "mysql -u ipsk -p ipsk -e 'SELECT mac, description, groupName, enabled FROM endpoint LIMIT 20'"
Search by partial MAC or description
ssh ipsk-mgr-01 "mysql -u ipsk -p ipsk -e \"SELECT mac, description, groupName, enabled FROM endpoint WHERE mac LIKE '%AA:BB%' OR description LIKE '%device-name%'\""
The -p flag (no space, no password) prompts interactively. Never put the password on the command line — it would be visible in ps aux and shell history.
Step 4 — Check WLC Client View
dsource d000 dev/network/wlc

# All clients
bash $(find ~/atelier -name 'get-clients.sh' -path '*/wlc*') \
  | tee /tmp/wlc-clients.json | jq '.'

# Filter by MAC (WLC uses dot notation: aabb.ccdd.eeff)
jq '.. | objects | select(.["client-mac"]? // "" | ascii_downcase | contains("aabb"))' \
  /tmp/wlc-clients.json
Step 5 — Auth History (Why Did It Fail?)
dsource d000 dev/network/ise

# Last 7 days of auth attempts for this MAC
bash $(find ~/atelier -name 'auth-history.sh' -path '*/mnt/*') AA:BB:CC:DD:EE:FF full 168 \
  | tee /tmp/ise-auth-history.json | jq '.'
Step 6 — Force Re-authentication (CoA)
# Reauth — device re-authenticates without disconnecting
bash $(find ~/atelier -name 'coa-reauth.sh' -path '*/mnt/*') AA:BB:CC:DD:EE:FF

# Disconnect — kills session, device must reconnect from scratch
bash $(find ~/atelier -name 'coa-reauth.sh' -path '*/mnt/*') AA:BB:CC:DD:EE:FF disconnect
gopass YAML Credential Store

All iPSK Manager credentials stored in a single YAML-formatted gopass entry with _use: keys explaining every field’s purpose.

Path
v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation
List all keys
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r 'keys[]'
Query specific values (safe — no password exposure)
# Server info
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.hostname'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.ip'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.os'

# DB users
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.db_user'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.ise_db_user'

# Purpose of each credential
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.db_use'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.ise_db_use'

# Stored procedures
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '[.sp_auth, .sp_fetch_password, .sp_mac_lookup, .sp_fetch_groups, .sp_fetch_attributes][]'
Validate passwords exist without exposing them (check length)
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.db_pass | length'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.ise_db_pass | length'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.db_root_pass | length'
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r '.ui_pass | length'
Validated (2026-06-21)
db_pass:      20 chars
ise_db_pass:  20 chars
db_root_pass: 10 chars
ui_pass:      24 chars
Test each credential live (from SSH to ipsk-mgr-01)
# App user
mysql -u ipsk -p ipsk -e "SELECT 1"

# ISE ODBC user
mysql -u ipsk-ise -p ipsk -e "SELECT 1"

# Root
mysql -u root -p -e "SELECT 1"
tail -n +2 skips the gopass password line (line 1). Without it, yq fails because gopass puts a bare password before the YAML content.
Common iPSK Issues
Symptom Likely Cause Fix

Device connects but no internet

Wrong VLAN from iPSK Manager

Check iPSK_AttributeFetch — verify VLAN assignment

Device fails auth entirely

MAC not registered in iPSK Manager

Add MAC via iPSK Manager web UI or direct SQL insert

Device gets wrong PSK

Stale entry or duplicate MAC

Check iPSK_FetchPasswordForMAC — verify PSK matches what’s configured on the device

Device connects then drops

ISE session timeout or reauth failure

Check auth history for repeated attempts, verify reauth timer

"This is a very good user"

iPSK stored procedure returning description field

That’s the user description — not an error

Device not in ISE active sessions

MAB failed or device not sending RADIUS

Check WLC client list — is the device associated to the AP?

Full Troubleshooting One-Liner

Replace AA:BB:CC:DD:EE:FF with the actual MAC:

MAC="AA:BB:CC:DD:EE:FF"
dsource d000 dev/network/ise
echo "=== ISE Session ===" && bash $(find ~/atelier -name 'active-sessions.sh' -path '*/ise*') | jq ".activeList.activeSession[]? | select(.calling_station_id | ascii_upcase | contains(\"${MAC%%:*}\"))"
echo "=== iPSK PSK ===" && ssh ipsk-mgr-01 "mysql -u ipsk -p ipsk -e \"CALL iPSK_FetchPasswordForMAC('${MAC}')\""
echo "=== iPSK Attributes ===" && ssh ipsk-mgr-01 "mysql -u ipsk -p ipsk -e \"CALL iPSK_AttributeFetch('${MAC}', @v); SELECT @v\""

WLC RESTCONF Enumeration — Full Inventory

First full RESTCONF enumeration of 9800-WLC-01 using the shell script library.

Enumeration Loop
dsource d000 dev/network/wlc
for script in get-hostname get-clients get-aps get-wlans \
  get-policy-tags get-site-tags get-rf-tags get-rf-profiles \
  get-policy-profiles get-ap-profiles get-aaa get-radius \
  get-rrm get-vlans get-svi get-switchport get-cpu get-memory; do
  echo "=== ${script} ==="
  bash $(find ~/atelier -name "${script}.sh" -path '*/wlc*')
  echo
done
Results (2026-06-21)
Hostname
9800-WLC-01
Table 5. Connected Clients (3)
MAC AP WLAN ID Auth Method

14:f6:d8:7b:31:80

C9130AX-01

4 (Domus-Secure)

dot1x — p50.inside.domusdigitalis.dev

a0:59:50:c9:92:ee

C9130AX-01

5 (Domus-IoT)

MAB (iPSK) — A0-59-50-C9-92-EE

e0:d5:5d:6c:e1:66

C9130AX-01

4 (Domus-Secure)

dot1x — modestus-p16g.inside.domusdigitalis.dev

Table 6. Access Points (1)
Name Model IP Serial Software

C9130AX-01

CW9166I-B

10.50.10.117

FJC29201LJZ

17.15.4.160

Table 7. WLANs (2)
SSID WLAN ID Profile Auth

Domus-IoT

5

Domus-IoT

iPSK (MAC filtering via ISE-AuthZ)

Domus-Secure

4

Domus-Secure

802.1X (EAP-TLS via ISE-Auth)

Table 8. Memory
Pool Total Used

Processor

12.5 GB

411 MB (3.3%)

Reserve

100 KB

92 B

lsmpi_io

3.1 MB

3.1 MB

Key Observations
  • Your P16g is client e0:d5:5d:6c:e1:66 — authenticated via dot1x as modestus-p16g.inside.domusdigitalis.dev on Domus-Secure (WLAN 4)

  • iPSK client a0:59:50:c9:92:ee — authenticated via MAB on Domus-IoT (WLAN 5), has ipsk-tag values confirming ISE returned iPSK attributes

  • AP CW9166I-B — Meraki-capable (meraki-connect-status: am-status-reg-success), SUDI cert valid until 2099, 3 radio slots, 1200 max clients

  • get-wlc-info returned invalid-value — the YANG path Cisco-IOS-XE-wireless-oper:wireless-oper-data/wlc-info doesn’t exist on this IOS-XE version. Script updated to fall back.

Script Library Location
data/d000/infra/wlc-api-reference/scripts/
├── lib/
│   └── wlc-common.sh          ← dsource creds, curl wrappers
└── restconf/
    ├── enumerate-all.sh        ← full dump to JSON (22 resources)
    │
    │   ── Wireless Operational ──
    ├── get-hostname.sh
    ├── get-clients.sh          ← table/count/full
    ├── get-aps.sh              ← table/count/full
    ├── get-wlans.sh            ← table/names/full
    ├── get-rrm.sh              ← rogues/full (Radio Resource Management)
    │
    │   ── Wireless Config (Tag Model) ──
    ├── get-policy-tags.sh      ← tag → WLAN profile → policy profile
    ├── get-site-tags.sh        ← tag → AP profile → flex profile → mode
    ├── get-rf-tags.sh          ← tag → 2.4/5/6 GHz RF profiles
    ├── get-rf-profiles.sh      ← name → band → channel width → TX power
    ├── get-policy-profiles.sh  ← name → VLAN → AAA override → switching
    ├── get-ap-profiles.sh      ← AP join profiles
    │
    │   ── AAA / RADIUS ──
    ├── get-aaa.sh              ← summary/groups/authn/authz/full
    ├── get-radius.sh           ← name → IP → ports → automate tester
    │
    │   ── L2/L3 (Native IOS-XE) ──
    ├── get-vlans.sh            ← VLAN database (ID → name)
    ├── get-svi.sh              ← SVI config (IP → mask → helper → state)
    ├── get-switchport.sh       ← trunk/access (native → allowed VLANs)
    │
    │   ── System Health ──
    ├── get-cpu.sh              ← summary/top/full
    ├── get-memory.sh           ← table/full
    ├── get-interfaces.sh       ← oper stats (traffic, errors, flaps)
    ├── get-wlc-info.sh         ← system version/hostname
    └── save-config.sh          ← RPC: write mem
Usage from anywhere
dsource d000 dev/network/wlc

# Individual scripts
bash $(find ~/atelier -name 'get-clients.sh' -path '*/wlc*') table
bash $(find ~/atelier -name 'get-wlans.sh' -path '*/wlc*') names
bash $(find ~/atelier -name 'get-aps.sh' -path '*/wlc*') table

# Full dump
bash $(find ~/atelier -name 'enumerate-all.sh' -path '*/wlc*') /tmp/wlc-dump.json

# Enumeration loop — operational + config
for script in get-hostname get-clients get-aps get-wlans \
  get-policy-tags get-site-tags get-rf-tags get-rf-profiles \
  get-policy-profiles get-ap-profiles get-aaa get-radius \
  get-rrm get-vlans get-svi get-switchport get-cpu get-memory; do
  echo "=== ${script} ==="
  bash $(find ~/atelier -name "${script}.sh" -path '*/wlc*')
  echo
done
ISE Guest SSID + Portal Configuration

Goal: Create a guest SSID on 9800-WLC-01 with CWA (Central Web Authentication) redirecting to an ISE hotspot portal, consuming the automated Let’s Encrypt certificate (CN=guest.domusdigitalis.dev).

Context

The ACME cert automation pipeline is proven (phases 0–7 validated 2026-06-16). The Let’s Encrypt portal cert is imported and bound to Portal role + Default Portal Certificate Group. No guest portal or authorization policy exists yet on ISE to serve actual client traffic.

Infrastructure Audit (2026-06-21)

Component Detail

WLC

9800-WLC-01 (10.50.1.40) — IOS-XE 17.15.4, virtual on kvm-01

AP

C9130AX-01 (CW9166I-B) — 10.50.10.117, firmware 17.15.4.160

ISE

ise-01.inside.domusdigitalis.dev (10.50.1.20)

Gateway

vyos-01 — VRRP VIP 10.50.30.1/24

Portal cert

LE-guest.domusdigitalis.dev-2026-06-16 (Portal role, expires Sep 14 2026)

Portal FQDN

guest.domusdigitalis.dev

Portal type

Hotspot (AUP accept, no credentials)

Guest VLAN

30 (GUEST_VLAN) — 10.50.30.0/24

DHCP

10.50.30.100–199, gateway 10.50.30.1, DNS 10.50.1.90/91, 1h lease

Existing WLC WLANs

ID Profile SSID Auth

4

Domus-Secure

Domus-Secure

802.1X (ISE-Auth)

5

Domus-IoT

Domus-IoT

iPSK + MAC filter (ISE-AuthZ)

Policy tags: default-policy-tag and TAG-DOMUS map both WLANs. RADIUS: ISE-GROUP (ISE-1: 10.50.1.20, ISE-2: 10.50.1.21), CoA configured. AAA: ISE-AuthZ network authorization list exists.

Existing ISE Guest Infrastructure

  • Authorization profiles: Domus_Guest_Hotspot_Redirect, Guest-CWA-Redirect-Hardened, Domus_Guest_Profile

  • Hotspot portal: Hotspot Guest Portal (default) — port 8445, cert group Default Portal Certificate Group

  • Cert serving on 8443: confirmed CN=guest.domusdigitalis.dev (issuer YR1, expires Sep 14 2026)

VyOS VLAN 30 Configuration (Verified)

firewall group network-group NET_GUEST network '10.50.30.0/24'
high-availability vrrp group GUEST address 10.50.30.1/24
interfaces ethernet eth1 vif 30 address '10.50.30.2/24'
interfaces ethernet eth1 vif 30 description 'GUEST'
service dhcp-server shared-network-name GUEST subnet 10.50.30.0/24 lease '3600'
service dhcp-server shared-network-name GUEST subnet 10.50.30.0/24 option default-router '10.50.30.1'
service dhcp-server shared-network-name GUEST subnet 10.50.30.0/24 option name-server '10.50.1.90'
service dhcp-server shared-network-name GUEST subnet 10.50.30.0/24 option name-server '10.50.1.91'
service dhcp-server shared-network-name GUEST subnet 10.50.30.0/24 range 0 start '10.50.30.100'
service dhcp-server shared-network-name GUEST subnet 10.50.30.0/24 range 0 stop '10.50.30.199'

VyOS Zone-Based Firewall (Verified)

Direction Action Detail

GUEST → WAN

accept

Internet access (default-action accept)

GUEST → LOCAL

filtered

Established/related + VRRP (proto 112) + DHCP (udp/67) + DNS (tcp_udp/53) only

GUEST → DATA

drop

Full isolation

GUEST → IOT

drop

Full isolation

GUEST → MGMT

drop

Full isolation

GUEST → VOICE

drop

Full isolation

LOCAL → GUEST

filtered

VRRP only

MGMT → GUEST

accept

Management can reach guest for troubleshooting

Zone member: eth1.30. Guest traffic is internet-only — no east-west to any internal VLAN.

WLC Trunk

GigabitEthernet1: trunk, allowed VLANs 10,20,30,40,100,110,120,666,999, native 100. VLAN 30 traffic flows AP → WLC Gi1 → upstream (VyOS eth1 vif 30).

Steps Remaining

  1. [x] Confirm VyOS firewall isolates guest (internet-only, no east-west)

  2. [ ] WLC: Create WLAN Domus-Guest (WLAN ID 6, open + MAC filter → ISE-AuthZ)

  3. [ ] WLC: Create policy profile POLICY-DOMUS_GUEST (VLAN 30, CWA redirect ACL)

  4. [ ] WLC: Add WLAN→profile mapping to default-policy-tag and TAG-DOMUS

  5. [ ] WLC: Create redirect ACL (ACL-GUEST-REDIRECT) — permit ISE + DNS, deny all

  6. [ ] ISE: Verify/create authorization policy rules (MAB unknown → redirect → permit after AUP)

  7. [ ] End-to-end test: client → SSID → DHCP → redirect → AUP → internet

Session Closeout

Re-encrypt before commit — both projects were opened
d001 close acme-cert
d001 close cert-renewal

Resume Checklist (Next Session)

  1. SSH to 9800-WLC-01 via kvm-01: ssh kvm-01ssh 9800-wlc-01

  2. Enter config mode: configure terminal

  3. Paste 4 blocks from data/d001/projects/ise-acme-cert-automation/partials/phase-04-guest-portal.adoc — in order: ACL → WLAN → Policy Profile → Policy Tag mappings

  4. Verify ISE authorization policy rules (API calls in phase-04 partial, Step 5)

  5. Test: connect device to Domus-Guest → redirect → AUP → internet

  6. Update phase-04 partial with results

  7. d001 close acme-cert before committing

WLC Configuration (CLI via SSH)

WLAN Profile
wlan Domus-Guest 6 Domus-Guest
 mac-filtering ISE-AuthZ
 no security wpa
 no security wpa akm dot1x
 no security wpa wpa2
 no security wpa wpa2 ciphers aes
 no shutdown
Policy Profile
wireless profile policy POLICY-DOMUS_GUEST
 description Guest CWA - VLAN 30
 vlan 30
 aaa-override
 nac
 aaa-policy Domus-AAA-Policy
 accounting-list ISE-ACCT
 idle-timeout 300
 session-timeout 3600
 exclusionlist timeout 60
 no shutdown
Policy Tag Mapping
wireless tag policy default-policy-tag
 wlan Domus-Guest policy POLICY-DOMUS_GUEST

wireless tag policy TAG-DOMUS
 wlan Domus-Guest policy POLICY-DOMUS_GUEST
Redirect ACL (permit = do NOT redirect, implicit deny = redirect)
ip access-list extended ACL-GUEST-REDIRECT
 permit udp any any eq 53
 permit udp any eq 53 any
 permit ip any host 10.50.1.20
 permit ip host 10.50.1.20 any
 permit ip any host 10.50.1.21
 permit ip host 10.50.1.21 any

ISE Authorization Policy (to verify/create)

Rule Condition Profile Result

Guest_Redirect

Wireless_MAB AND EndPointsNotKnown

Domus_Guest_Hotspot_Redirect

CWA redirect to hotspot portal

Guest_Permit

Wireless_MAB AND Guest_Flow (PermitAccess after AUP)

Domus_Guest_Profile

VLAN 30 internet access

802.1X Wired Authentication — EAP-TLS and EAP-TEAP

Configuring wired 802.1X on an Arch Linux workstation (ThinkPad P16g) using wpa_supplicant with certificate-based authentication against Cisco ISE. Two profiles: EAP-TLS (direct) and EAP-TEAP (tunneled). Both use the same PKI — the difference is the outer tunnel.

Step 0 — Inventory Network Profiles
nmcli -t -f NAME,TYPE,DEVICE,ACTIVE con show | awk -F: '{
  type=$2
  gsub(/^802-11-wireless$/, "wifi", type)
  gsub(/^802-3-ethernet$/, "eth", type)
  dev = ($3 == "" ? "-" : $3)
  st  = ($4 == "yes" ? "ON" : "--")
  key = ($4 == "yes" ? 0 : 1)
  printf "%d\t%-3s %-32s %-8s %-12s\n", key, st, $1, type, dev
}' | sort -k1,1n -k3,3 | cut -f2- | { printf "%-3s %-32s %-8s %-12s\n%-3s %-32s %-8s %-12s\n" "ST" "NAME" "TYPE" "DEVICE" "--" "----" "----" "------"; cat; }
Expected output
ST  NAME                             TYPE     DEVICE
--  ----                             ----     ------
ON  Domus-WiFi-Mgmt-VLAN100          wifi     wlan0
--  Domus-Wired-Mgmt-VLAN100         eth      -
--  Domus-Wired-Open                 eth      -

Identify the wired interface:

ip -br link show | grep -E 'enp|eth'
Expected output
enp134s0         DOWN           a8:2b:dd:8f:23:e6
Step 1 — Verify Certificate Chain (Before Touching wpa_supplicant)

These four checks validate the entire PKI stack. If any fail, do not proceed — ISE will reject the handshake and the error will be harder to diagnose from RADIUS logs.

1a. Verify client cert validates against CA chain
openssl verify -CAfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem \
  /etc/ssl/certs/modestus-p16g-eaptls.pem
Expected
/etc/ssl/certs/modestus-p16g-eaptls.pem: OK
1b. Confirm private key matches certificate (modulus hashes must be identical)
openssl x509 -noout -modulus -in /etc/ssl/certs/modestus-p16g-eaptls.pem | openssl md5
sudo openssl rsa -noout -modulus -in /etc/ssl/private/modestus-p16g-eaptls.key | openssl md5
Expected — both hashes match
MD5(stdin)= 93b47ebd1f0cd7c8886ce8b2e411a6a0
MD5(stdin)= 93b47ebd1f0cd7c8886ce8b2e411a6a0
The private key requires sudo/etc/ssl/private/ is mode 0700 owned by root. This is correct. If you can read the key without sudo, your permissions are wrong.
1c. Confirm identity and expiry
openssl x509 -noout -subject -issuer -enddate -in /etc/ssl/certs/modestus-p16g-eaptls.pem
Expected
subject=O=Domus-Infrastructure, OU=Domus-Admins, CN=modestus-p16g.inside.domusdigitalis.dev
issuer=CN=DOMUS-ISSUING-CA
notAfter=Apr 14 20:05:03 2027 GMT
1d. Verify CA chain order (issuing → root)
openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem | \
  openssl pkcs7 -print_certs -noout
Expected — issuing CA first, then self-signed root
subject=CN=DOMUS-ISSUING-CA
issuer=C=US, O=Domus Digitalis, OU=Enterprise PKI, CN=DOMUS-ROOT-CA

subject=C=US, O=Domus Digitalis, OU=Enterprise PKI, CN=DOMUS-ROOT-CA
issuer=C=US, O=Domus Digitalis, OU=Enterprise PKI, CN=DOMUS-ROOT-CA
Validation Summary
Check What It Proves Result

openssl verify

Client cert was signed by your CA, chain is complete

OK

Modulus MD5 match

Private key belongs to the client cert (not a stale key from a previous issuance)

Match

Subject/issuer/expiry

Cert identity matches the ISE endpoint record, not expired

Valid

Chain order

CA chain file has issuing CA before root — correct PEM concatenation order

Correct

Step 2 — Write wpa_supplicant Config
Profile A: EAP-TLS (Direct Certificate Auth)

The standard. Client presents certificate directly to the RADIUS server. One TLS handshake, no tunnel. This is what most enterprise 802.1X deployments use for machine authentication.

sudo tee /etc/wpa_supplicant/wpa_supplicant-eaptls-enp134s0.conf << 'EOF'
# EAP-TLS — direct certificate authentication
# ISE allowed protocols: Domus_TEAP_TLS_TTLS (EAP-TLS enabled)
# Identity: modestus-p16g.inside.domusdigitalis.dev
# Issuer: DOMUS-ISSUING-CA → DOMUS-ROOT-CA
ctrl_interface=/run/wpa_supplicant
ctrl_interface_group=wheel
eapol_version=2
ap_scan=0
fast_reauth=1

network={
    key_mgmt=IEEE8021X
    eap=TLS
    identity="modestus-p16g.inside.domusdigitalis.dev"
    ca_cert="/etc/ssl/certs/DOMUS-CA-CHAIN.pem"
    client_cert="/etc/ssl/certs/modestus-p16g-eaptls.pem"
    private_key="/etc/ssl/private/modestus-p16g-eaptls.key"
    eapol_flags=0
}
EOF
Profile B: EAP-TEAP (Tunneled Certificate Auth)

EAP-TEAP wraps the inner EAP-TLS inside an outer TLS tunnel. Two TLS handshakes: outer authenticates the server (anonymous), inner authenticates the client (your cert). Provides identity privacy — the client identity is encrypted inside the tunnel.

sudo tee /etc/wpa_supplicant/wpa_supplicant-teap-enp134s0.conf << 'EOF'
# EAP-TEAP (inner EAP-TLS) — tunneled certificate authentication
# ISE allowed protocols: Domus_TEAP_TLS_TTLS (TEAP enabled)
# Identity: modestus-p16g.inside.domusdigitalis.dev
# Issuer: DOMUS-ISSUING-CA → DOMUS-ROOT-CA
ctrl_interface=/run/wpa_supplicant
ctrl_interface_group=wheel
eapol_version=2
ap_scan=0
fast_reauth=1

network={
    key_mgmt=IEEE8021X
    eap=TEAP
    identity="modestus-p16g.inside.domusdigitalis.dev"
    ca_cert="/etc/ssl/certs/DOMUS-CA-CHAIN.pem"
    phase2="auth=TLS"
    client_cert="/etc/ssl/certs/modestus-p16g-eaptls.pem"
    private_key="/etc/ssl/private/modestus-p16g-eaptls.key"
    eapol_flags=0
}
EOF
Verify configs were written
ls -la /etc/wpa_supplicant/wpa_supplicant-*-enp134s0.conf
Step 3 — Bring Up Interface and Authenticate
# Bring up the wired interface
sudo ip link set dev enp134s0 up
ip -br link show enp134s0
Expected — UP or NO-CARRIER (no cable yet)
enp134s0  UP  a8:2b:dd:8f:23:e6 <BROADCAST,MULTICAST,UP>

Once confirmed UP, test EAP-TLS or EAP-TEAP:

====== Test EAP-TLS first (simpler — one handshake, fewer failure points)

[source,bash]

sudo wpa_supplicant -D wired -i enp134s0 \ -c /etc/wpa_supplicant/wpa_supplicant-eaptls-enp134s0.conf -d

.What to watch for in debug output
[source,console]

SUCCESS sequence:

EAP: EAP entering state IDENTITY EAP: received EAP-Request id=X type=13 (TLS) ←- ISE requests EAP-TLS SSL: SSL_connect:SSLv3/TLS write client hello SSL: SSL_connect:SSLv3/TLS read server hello EAP-TLS: Done ←- TLS handshake complete CTRL-EVENT-EAP-SUCCESS EAP authentication completed ←- ISE accepted CTRL-EVENT-CONNECTED ←- port authorized

FAILURE indicators:

CTRL-EVENT-EAP-FAILURE ←- check ISE live logs SSL: SSL3 alert read:fatal:unknown CA ←- ISE doesn’t trust your CA SSL: SSL3 alert read:fatal:certificate expired ←- cert expired

Press `Ctrl-C` to stop after observing the result.

====== Test EAP-TEAP (tunneled — two handshakes)

[source,bash]

sudo wpa_supplicant -D wired -i enp134s0 \ -c /etc/wpa_supplicant/wpa_supplicant-teap-enp134s0.conf -d

.What to watch for in debug output
[source,console]

SUCCESS sequence:

EAP: received EAP-Request type=55 (TEAP) ←- ISE requests TEAP EAP-TEAP: Phase 1 - negotiated ←- outer TLS tunnel up EAP-TEAP: Phase 2 - TLS ←- inner EAP-TLS starts EAP-TEAP: Authentication completed successfully CTRL-EVENT-EAP-SUCCESS CTRL-EVENT-CONNECTED

FAILURE indicators:

EAP-TEAP: Phase 1 failed ←- server cert issue EAP-TEAP: Phase 2 - Failure ←- inner auth rejected (cert or config)

===== Step 4 — Post-Auth Validation (Run in a Second Terminal)

Open a second terminal *before* starting wpa_supplicant. These commands monitor the transaction from multiple vantage points simultaneously.

====== 4a. Local system logs — wpa_supplicant journal

[source,bash]

Live tail of wpa_supplicant logs (run BEFORE starting wpa_supplicant)

journalctl -f -u 'wpa_supplicant*' -o short-iso

If running manually (not via systemd), tail the wpa_supplicant syslog tag

journalctl -f -t wpa_supplicant -o short-iso

====== 4b. EAPOL frame activity on the wire

[source,bash]

Watch 802.1X frames on the wired interface (EAPOL = ethertype 0x888e)

sudo tcpdump -i enp134s0 -nn -vv 'ether proto 0x888e' -c 30

Or capture to file for later analysis in Wireshark

sudo tcpdump -i enp134s0 -nn -w /tmp/eapol-test-$(date +%Y%m%d-%H%M%S).pcap 'ether proto 0x888e'

====== 4c. RADIUS transaction (if switch is sending to ISE)

[source,bash]

Watch RADIUS packets from the switch to ISE (run on ISE or mirror port)

On this machine — watch for DHCP after successful auth (port opens → DHCP starts)

sudo tcpdump -i enp134s0 -nn 'port 67 or port 68' -c 10

====== 4d. Interface state changes

[source,bash]

Monitor link state + IP assignment in real-time

ip monitor link dev enp134s0

In another pane — watch for IP assignment (802.1X success → DHCP → IP)

ip monitor address dev enp134s0

====== 4e. Kernel audit log (auditd is now active)

[source,bash]

Watch for wpa_supplicant and SSL-related syscalls

sudo ausearch -sc execve -x wpa_supplicant --start recent -i

All sudo commands during the test window

journalctl _COMM=sudo --since "5 minutes ago" -o short-iso --no-pager

====== 4f. Post-auth checklist (run after CTRL-EVENT-EAP-SUCCESS)

[source,bash]

Did we get an IP?

ip -4 addr show enp134s0

Can we reach the gateway?

ping -c 3 -I enp134s0 10.50.1.1

DNS working?

dig +short @10.50.1.50 ise-01.inside.domusdigitalis.dev

wpa_cli status (if ctrl_interface is active)

sudo wpa_cli -i enp134s0 status

===== Step 5 — Verify on ISE

After a successful authentication, verify the session from the network side:

[source,bash]

dsource d000 dev/network/ise

Dump active sessions to file, then query

bash $(find ~/atelier -name 'active-sessions.sh' -path '/ise') \ | tee /tmp/ise-active-sessions.json | jq '.activeList | length'

Filter for P16g wired MAC

jq '.activeList[]? | select(.calling_station_id | ascii_upcase | contains("A8:2B:DD:8F:23:E6"))' \ /tmp/ise-active-sessions.json

Table view

bash $(find ~/atelier -name 'active-sessions.sh' -path '/ise') table

Or check ISE GUI: *Operations → RADIUS → Live Logs* — filter by calling station ID `A8:2B:DD:8F:23:E6`.

===== Step 6 — Compare EAP-TLS vs EAP-TEAP in ISE Logs

After testing both profiles, compare what ISE recorded:

[source,bash]

dc-run-sql << 'SQL' SELECT timestamp, authentication_method, eap_tunnel, eap_authentication, authorization_profiles, failure_reason FROM RADIUS_AUTHENTICATION WHERE calling_station_id LIKE '%A82BDD8F23E6%' ORDER BY timestamp DESC FETCH FIRST 10 ROWS ONLY; SQL

===== Contingency — Rollback to WiFi

IMPORTANT: Your WiFi connection (`Domus-WiFi-Mgmt-VLAN100` on `wlan0`) remains active during this entire test. The wired test is on a *separate interface* (`enp134s0`). You do not lose connectivity.

====== If wpa_supplicant fails or ISE rejects

[source,bash]

1. Kill the test wpa_supplicant (Ctrl-C in the debug terminal, or:)

sudo pkill -f 'wpa_supplicant.*enp134s0'

2. Bring down the wired interface

sudo ip link set enp134s0 down

3. Confirm WiFi is still active

nmcli -t -f NAME,DEVICE,STATE con show --active | grep wlan0

4. Confirm internet works over WiFi

ping -c 2 -I wlan0 10.50.1.1

====== If you made it permanent and need to undo

[source,bash]

Disable the systemd service

sudo systemctl disable --now wpa_supplicant-wired@enp134s0.service

Remove the symlink

sudo rm -f /etc/wpa_supplicant/wpa_supplicant-wired-enp134s0.conf

Bring down the interface

sudo ip link set enp134s0 down

Verify — should show no wpa_supplicant on enp134s0

ps aux | grep 'wpa_supplicant.*enp134s0'

====== If wired works and you want to switch from WiFi to wired

[source,bash]

Disconnect WiFi (optional — you can run both)

nmcli con down Domus-WiFi-Mgmt-VLAN100

Verify wired is your only active connection

ip route show default

====== Emergency: lost all connectivity

[source,bash]

Reconnect WiFi immediately

nmcli con up Domus-WiFi-Mgmt-VLAN100

If NetworkManager is confused, restart it

sudo systemctl restart NetworkManager nmcli con up Domus-WiFi-Mgmt-VLAN100

===== Side-by-Side Comparison

[cols="1,1,1"]
|===
| Property | EAP-TLS | EAP-TEAP

| wpa_supplicant `eap=`
| `TLS`
| `TEAP`

| Inner method
| _(none — direct)_
| `phase2="auth=TLS"`

| TLS handshakes
| 1
| 2 (outer tunnel + inner auth)

| Identity privacy
| Exposed in cleartext on the wire
| Encrypted inside outer tunnel

| Same certs?
| Yes
| Yes

| ISE auth log
| `eap_authentication=EAP-TLS`
| `eap_tunnel=TEAP`, `eap_authentication=EAP-TLS`

| RFC
| RFC 5216
| RFC 7170

| Primary use case
| Machine auth, Linux/macOS clients
| Windows BYOD, identity-sensitive environments
|===

===== Step 6 — Make Permanent (After Test Succeeds)

[source,bash]

Symlink the chosen profile to the standard name

sudo ln -sf wpa_supplicant-eaptls-enp134s0.conf \ /etc/wpa_supplicant/wpa_supplicant-wired-enp134s0.conf

Enable the systemd wired template for this interface

sudo systemctl enable --now wpa_supplicant-wired@enp134s0.service

Verify it’s running

systemctl status wpa_supplicant-wired@enp134s0.service

Verify it survives reboot

sudo systemctl is-enabled wpa_supplicant-wired@enp134s0.service

===== Troubleshooting Quick Reference

[cols="1,2,2"]
|===
| ISE Error Code | Meaning | Fix

| 12514
| EAP-TLS handshake failed
| CA chain not trusted on ISE — import DOMUS-ROOT-CA to ISE trust store

| 12308
| Client cert not found in trust store
| ISE doesn't have the issuing CA — check *Administration → Certificates → Trusted Certificates*

| 12321
| Inner method rejected
| TEAP `phase2` mismatch — verify `auth=TLS` and that Allowed Protocols has TEAP+inner-TLS enabled

| 22056
| TEAP not in allowed protocols
| Add TEAP to the Allowed Protocols list assigned to the policy set

| 22040
| Wrong EAP type
| Client offered an EAP method not in the Allowed Protocols list
|===

.Verify cert chain independently
[source,bash]

Does the client cert validate?

openssl verify -CAfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem \ /etc/ssl/certs/modestus-p16g-eaptls.pem

Does ISE’s server cert validate from the client’s perspective?

openssl s_client -connect ise-01.inside.domusdigitalis.dev:1812 \ -CAfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem < /dev/null 2>/dev/null | \ grep -E 'Verify|subject|issuer'

===== Config Parameter Reference

[cols="1,2"]
|===
| Parameter | Purpose

| `ctrl_interface=/run/wpa_supplicant`
| Unix socket for `wpa_cli` control — allows runtime queries and profile switching

| `ctrl_interface_group=wheel`
| Group that can access the control socket without root

| `eapol_version=2`
| 802.1X EAPOL frame version — v2 is current standard (v1 for legacy switches)

| `ap_scan=0`
| Wired mode — don't scan for wireless APs, wait for authenticator

| `fast_reauth=1`
| Enable TLS session resumption — faster re-authentication after link flap

| `key_mgmt=IEEE8021X`
| Use 802.1X key management (not WPA-PSK, not open)

| `eapol_flags=0`
| No dynamic WEP keys — wired 802.1X doesn't use per-station encryption

| `phase2="auth=TLS"`
| Inner authentication method inside TEAP tunnel — EAP-TLS (certificate)

| `ca_cert`
| CA chain to validate the RADIUS server's certificate

| `client_cert`
| Your certificate — presented to ISE during EAP-TLS handshake

| `private_key`
| Your private key — proves you own the certificate (never leaves this machine)
|===

==== EAP-TEAP Wired Test — P16g

Testing EAP-TEAP (inner EAP-TLS) on the P16g wired interface against ISE.

===== TEAP Status: Not Available

Arch Linux `wpa_supplicant 2:2.11-5` is compiled **without** `CONFIG_EAP_TEAP=y`. The OpenSSL string found earlier (`Enable cipher suites for anonymous EAP-TEAP provisioning`) is from the OpenSSL library, not the wpa_supplicant EAP module.

[source,bash]

This returns nothing — no TEAP EAP module

strings $(which wpa_supplicant) | grep -i '^eap_teap'

The earlier false positive was from OpenSSL, not wpa_supplicant

strings $(which wpa_supplicant) | grep -i teap # "OpenSSL: Enable cipher suites for anonymous EAP-TEAP provisioning" # ^ This is OpenSSL’s TLS layer, not an EAP method

To enable TEAP, rebuild wpa_supplicant from AUR with `CONFIG_EAP_TEAP=y` in the build config. This is a future task — EAP-TLS works and is the production standard.

===== Prerequisites Verified

[cols="1,2,1"]
|===
| Component | Path / Value | Status

| Wired interface
| `enp134s0` (a8:2b:dd:8f:23:e6)
| PASS

| wpa_supplicant
| v2.11 — EAP-TEAP **NOT** compiled in
| ❌ (EAP-TLS works)

| Client cert
| `/etc/ssl/certs/modestus-p16g-eaptls.pem`
| Valid until 2027-04-14

| Private key
| `/etc/ssl/private/modestus-p16g-eaptls.key`
| PASS

| CA chain
| `/etc/ssl/certs/DOMUS-CA-CHAIN.pem`
| DOMUS-ISSUING-CA → DOMUS-ROOT-CA

| ISE allowed protocols
| `Domus_TEAP_TLS_TTLS`
| TEAP enabled

| systemd template
| `wpa_supplicant-wired@.service`
| disabled (available)
|===

===== Step 1 — Write wpa_supplicant Config

[source,bash]

sudo tee /etc/wpa_supplicant/wpa_supplicant-wired-enp134s0.conf << 'EOF' # EAP-TEAP (inner EAP-TLS) — modestus-p16g wired 802.1X # ISE policy set: Domus_TEAP_TLS_TTLS ctrl_interface=/run/wpa_supplicant ctrl_interface_group=wheel eapol_version=2 ap_scan=0 fast_reauth=1

network={ key_mgmt=IEEE8021X eap=TEAP identity="modestus-p16g.inside.domusdigitalis.dev" ca_cert="/etc/ssl/certs/DOMUS-CA-CHAIN.pem" phase2="auth=TLS" client_cert="/etc/ssl/certs/modestus-p16g-eaptls.pem" private_key="/etc/ssl/private/modestus-p16g-eaptls.key" eapol_flags=0 } EOF

===== Step 2 — Open Debug Terminals (BEFORE Starting wpa_supplicant)

Open these in separate tmux panes or terminal tabs *before* step 3.

.Terminal 2 — wpa_supplicant journal (live)
[source,bash]

journalctl -f -t wpa_supplicant -o short-iso

.Terminal 3 — EAPOL frames on the wire (802.1X = ethertype 0x888e)
[source,bash]

sudo tcpdump -i enp134s0 -nn -vv 'ether proto 0x888e' -c 30

.Terminal 4 — interface state + IP assignment
[source,bash]

ip monitor link dev enp134s0 & ip monitor address dev enp134s0

.Terminal 5 — DHCP after port authorization
[source,bash]

sudo tcpdump -i enp134s0 -nn 'port 67 or port 68' -c 10

.Terminal 6 — NetworkManager events
[source,bash]

journalctl -f -u NetworkManager -o short-iso

.Terminal 7 — auditd syscall trace
[source,bash]

sudo ausearch -sc execve -x wpa_supplicant --start recent -i

===== Step 3 — Disconnect NetworkManager + Start wpa_supplicant

NetworkManager manages `enp134s0` via the `Domus-Wired-Mgmt-VLAN100` profile (EAP-TLS). You must disconnect NM first — you cannot run two wpa_supplicant instances on the same interface.

[source,bash]

Disconnect NM from the wired interface

nmcli con down Domus-Wired-Mgmt-VLAN100

[source,bash]

Bring up the wired interface

sudo ip link set dev enp134s0 up ip -br link show enp134s0

.Expected — UP or NO-CARRIER (no cable yet)
[source,console]

enp134s0 UP a8:2b:dd:8f:23:e6 <BROADCAST,MULTICAST,UP>

Once confirmed UP, start wpa_supplicant:

[source,bash]

sudo wpa_supplicant -D wired -i enp134s0 \ -c /etc/wpa_supplicant/wpa_supplicant-wired-enp134s0.conf \ -d

Watch for:

* `EAP-TEAP: Phase 1 - negotiated` — outer TLS tunnel established
* `EAP-TEAP: Phase 2 - TLS` — inner EAP-TLS handshake
* `CTRL-EVENT-EAP-SUCCESS` — ISE accepted authentication
* `CTRL-EVENT-CONNECTED` — 802.1X port authorized

===== Step 4 — Post-Auth Checklist (After CTRL-EVENT-EAP-SUCCESS)

[source,bash]

Did we get an IP?

ip -4 addr show enp134s0

Can we reach the gateway?

ping -c 3 -I enp134s0 10.50.1.1

DNS working?

dig +short @10.50.1.50 ise-01.inside.domusdigitalis.dev

wpa_cli status

sudo wpa_cli -i enp134s0 status

What profile is active on this interface?

nmcli -t -f NAME,DEVICE,STATE con show --active | grep enp134s0

Full connection detail

nmcli dev show enp134s0

===== Step 5 — Verify on ISE Side

[source,bash]

dsource d000 dev/network/ise

Dump active sessions to file, then query

bash $(find ~/atelier -name 'active-sessions.sh' -path '/ise') \ | tee /tmp/ise-active-sessions.json | jq '.activeList | length'

Filter for P16g wired MAC

jq '.activeList[]? | select(.calling_station_id | ascii_upcase | contains("A8:2B:DD:8F:23:E6"))' \ /tmp/ise-active-sessions.json

Table view

bash $(find ~/atelier -name 'active-sessions.sh' -path '/ise') table

===== Step 6 — Make Permanent (after test succeeds)

[source,bash]

Enable the systemd wired template for this interface

sudo systemctl enable --now wpa_supplicant-wired@enp134s0.service

===== Step 7 — Compare EAP-TLS vs EAP-TEAP

After both tests, compare ISE auth logs:

[source,bash]

Query DataConnect for auth events from this MAC

dc-run-sql << 'SQL' SELECT timestamp, authentication_method, eap_tunnel, eap_authentication, authorization_profiles, failure_reason FROM RADIUS_AUTHENTICATION WHERE calling_station_id LIKE '%A82BDD8F23E6%' ORDER BY timestamp DESC FETCH FIRST 10 ROWS ONLY; SQL

Key differences to observe:

* **EAP-TLS**: `eap_authentication=EAP-TLS`, no tunnel
* **EAP-TEAP**: `eap_tunnel=TEAP`, `eap_authentication=EAP-TLS` (inner method)
* TEAP provides the tunnel — the same certs, but wrapped in an additional TLS layer

===== Contingency — Rollback to WiFi

WiFi (`Domus-WiFi-Mgmt-VLAN100` on `wlan0`) stays active during the entire wired test — separate interface, separate connection. You cannot lose connectivity.

.If wpa_supplicant fails or ISE rejects
[source,bash]

Kill the test (Ctrl-C in debug terminal, or:)

sudo pkill -f 'wpa_supplicant.*enp134s0'

Bring down wired

sudo ip link set enp134s0 down

Confirm WiFi still active

nmcli -t -f NAME,DEVICE,STATE con show --active | grep wlan0 ping -c 2 -I wlan0 10.50.1.1

.If you made it permanent and need to undo
[source,bash]

sudo systemctl disable --now wpa_supplicant-wired@enp134s0.service sudo rm -f /etc/wpa_supplicant/wpa_supplicant-wired-enp134s0.conf sudo ip link set enp134s0 down

.Emergency — lost all connectivity
[source,bash]

nmcli con up Domus-WiFi-Mgmt-VLAN100

===== Troubleshooting

====== API Debug — Inspecting HTTP Transactions

These commands show the raw request/response cycle. Essential for diagnosing auth failures, content-type mismatches, and empty responses.

.Show HTTP status code + response body
[source,bash]

curl -s --cacert "${ISE_CA_CERT}" \ -u "${ISE_API_USER}:${ISE_API_PASS}" \ -H "Accept: application/xml" \ -w "\nHTTP_CODE: %{http_code}\n" \ "https://${ISE_PAN_FQDN}/admin/API/mnt/Session/ActiveCount"

.Full TLS handshake + HTTP headers (verbose)
[source,bash]

curl -vv --cacert "${ISE_CA_CERT}" \ -u "${ISE_API_USER}:${ISE_API_PASS}" \ -H "Accept: application/xml" \ "https://${ISE_PAN_FQDN}/admin/API/mnt/Session/ActiveCount" 2>&1 | head -60

.Common HTTP status codes from ISE
[cols="1,2,2"]
|===
| Code | Meaning | Fix

| 200
| Success
| Parse the response (XML for MnT, JSON for ERS/OpenAPI)

| 401
| Unauthorized
| Wrong credentials — verify `dsource` loaded the right tier

| 403
| Forbidden
| API user doesn't have MnT admin role in ISE

| 406
| Not Acceptable
| MnT only returns XML — use `Accept: application/xml`, not `application/json`

| 500
| Internal Server Error
| ISE bug or service restart — check ISE admin logs
|===

NOTE: **ISE MnT API returns XML only.** It returns HTTP 406 (Not Acceptable) when you send `Accept: application/json`. The ERS and OpenAPI endpoints return JSON. The `mnt()` function in `ise-common.sh` now uses `Accept: application/xml` and pipes through `yq -p xml -o json` for conversion.

====== EAP Failure Debugging

If `CTRL-EVENT-EAP-FAILURE`:

[source,bash]

Check ISE live logs for this MAC

ISE GUI: Operations → RADIUS → Live Logs → filter by MAC

Common failures:

12514 — EAP-TLS handshake failed (cert chain issue)

12308 — Client cert not found in trust store

12321 — Inner method rejected (phase2 mismatch)

22056 — TEAP not in allowed protocols list

If wpa_supplicant hangs at `EAP-TEAP: Phase 1`:

[source,bash]

Verify ISE trusts the client cert’s issuer

openssl verify -CAfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem \ /etc/ssl/certs/modestus-p16g-eaptls.pem

====== Live System Diagnostics

.wpa_supplicant activity (last 2 minutes)
[source,bash]

journalctl -t wpa_supplicant --since "2 minutes ago" -o short-iso --no-pager

.All sudo commands during this test
[source,bash]

journalctl _COMM=sudo --since "10 minutes ago" -o short-iso --no-pager

.NetworkManager events (interface state changes)
[source,bash]

journalctl -u NetworkManager --since "5 minutes ago" -o short-iso --no-pager | grep enp134s0

.auditd — every process wpa_supplicant spawned
[source,bash]

sudo ausearch -sc execve -x wpa_supplicant --start recent -i

.DHCP client activity (did we get an IP after port authorization?)
[source,bash]

journalctl -t dhcpcd --since "5 minutes ago" -o short-iso --no-pager journalctl -t systemd-networkd --since "5 minutes ago" -o short-iso --no-pager

.Kernel messages (link state changes, driver errors)
[source,bash]

dmesg --since "5 minutes ago" | grep -i 'enp134s0\|link\|carrier'

==== EAP-TLS Wired Test Results — 2026-06-21

First successful wired 802.1X EAP-TLS authentication on the P16g.

===== Test Outcome

[cols="1,2"]
|===
| Component | Result

| wpa_supplicant EAP-TLS handshake
| ✅ SUCCESS — `selectedMethod=13 (EAP-TLS)`

| Certificate chain validation
| ✅ DOMUS-ROOT-CA → DOMUS-ISSUING-CA → ise-01.inside.domusdigitalis.dev

| TLS cipher
| `ECDHE-RSA-AES256-GCM-SHA384` (TLSv1.2)

| IP assignment
| 10.50.1.203/24 via DHCP

| Gateway reachable
| ✅ 10.50.1.1 — 0.4ms RTT

| ISE RADIUS session
| FAILt visible — switch port in open/monitor mode

| NAS (switch)
| LAB-3560CX-01 (10.50.1.10) — Gi1/0/3

| Port description
| `[DOT1X] AIR-AP4800-B-K9` — provisioned for AP, not workstation
|===

===== Network Path Discovery

.LLDP capture — identify switch and port
[source,bash]

sudo tcpdump -i enp134s0 -nn -vv -c 1 \ 'ether proto 0x88cc or (ether[12:2] == 0x2000)' -l

.ARP neighbor — identify gateway device
[source,bash]

ip neigh show dev enp134s0 | grep 10.50.1.1

.Results
[cols="1,2"]
|===
| Discovery | Value

| Switch
| LAB-3560CX-01 (`9c:57:ad:48:a3:80`)

| Port
| Gi1/0/3

| Port template
| `[DOT1X]` — 802.1X configured

| Original purpose
| AIR-AP4800-B-K9 (AP port)

| Switch IP
| 10.50.1.10

| Switch IOS
| C3560CX-UNIVERSALK9-M 15.2(7)E2

| PVID
| 100 (management)

| Gateway
| 10.50.1.1 (`52:54:00:ac:44:8d` — VyOS KVM)

| ISE NAD name
| Home-3560CX-01 (registered in ISE ERS)
|===

===== Intel igc Driver — PTM Timeout Workaround

The P16g's Intel I225/I226 NIC (`igc` driver) fails to initialize with `Timeout reading IGC_PTM_STAT register`. Workaround:

[source,bash]

Reload the driver

sudo modprobe -r igc sudo modprobe igc

Disable PTM via PCI command register

sudo setpci -s 86:00.0 COMMAND=0x06

Now bring up the interface

sudo ip link set dev enp134s0 up ip -br link show enp134s0

`COMMAND=0x06` sets Bus Master Enable + Memory Space Enable — normal NIC operation. No security risk. This is a driver bug workaround, not a security bypass.

.Kernel messages showing the PTM timeout
[source,console]

igc 0000:86:00.0 enp134s0: Timeout reading IGC_PTM_STAT register

===== EAP-TLS Journal Output

[source,console]

enp134s0: CTRL-EVENT-EAP-STARTED EAP authentication started enp134s0: CTRL-EVENT-EAP-PROPOSED-METHOD vendor=0 method=13 enp134s0: CTRL-EVENT-EAP-METHOD EAP vendor 0 method 13 (TLS) selected enp134s0: CTRL-EVENT-EAP-PEER-CERT depth=2 subject='/C=US/O=Domus Digitalis/OU=Enterprise PKI/CN=DOMUS-ROOT-CA' enp134s0: CTRL-EVENT-EAP-PEER-CERT depth=1 subject='/CN=DOMUS-ISSUING-CA' enp134s0: CTRL-EVENT-EAP-PEER-CERT depth=0 subject='/CN=ise-01.inside.domusdigitalis.dev' enp134s0: CTRL-EVENT-EAP-SUCCESS EAP authentication completed successfully enp134s0: CTRL-EVENT-CONNECTED - Connection to 01:80:c2:00:00:03 completed [id=0 id_str=]

===== wpa_cli Status (Post-Auth)

[source,console]

Supplicant PAE state=AUTHENTICATED suppPortStatus=Authorized EAP state=SUCCESS selectedMethod=13 (EAP-TLS) eap_tls_version=TLSv1.2 EAP TLS cipher=ECDHE-RSA-AES256-GCM-SHA384 ip_address=10.50.1.203 address=a8:2b:dd:8f:23:e6

===== Network Connection Status (Post-Auth)

[source,console]

ST NAME TYPE DEVICE —  ---- ---- ------ ON Domus-WiFi-Mgmt-VLAN100 wifi wlan0 ON Domus-Wired-Mgmt-VLAN100 eth enp134s0

Both WiFi and wired active simultaneously — contingency path confirmed.

===== Why ISE Has No Session

The 3560CX port Gi1/0/3 has a `[DOT1X]` template but is likely in **open mode** or **monitor mode**:

* **Open mode** (`authentication open`): switch forwards traffic regardless of auth result. wpa_supplicant completes EAP, but the switch doesn't enforce the result and may not send RADIUS accounting.
* **Monitor mode** (`authentication host-mode multi-auth` with no enforcement): similar — auth happens but isn't enforced.

The port was originally provisioned for an AP (`AIR-AP4800-B-K9`), which typically uses MAB, not closed-mode dot1x.

===== Switch-Side Verification (IBNS 2.0)

SSH to the 3560CX requires legacy key exchange (IOS 15.2):

[source,bash]

ssh -oKexAlgorithms=+diffie-hellman-group14-sha1 -oHostKeyAlgorithms=+ssh-rsa 3560cx-01

====== Port config — Gi1/0/3 (where P16g is connected)

[source,console]

LAB-3560CX-01# show run int gi1/0/3

interface GigabitEthernet1/0/3 description [DOT1X] AIR-AP4800-B-K9 switchport access vlan 100 switchport mode access ip arp inspection trust source template DefaultWiredDot1xClosedAuth spanning-tree portfast edge

====== Access session — dot1x SUCCESS

[source,console]

LAB-3560CX-01# show access-session interface gi1/0/3 details

      MAC Address:  a82b.dd8f.23e6
     IPv4 Address:  10.50.1.203
        User-Name:  modestus-p16g.inside.domusdigitalis.dev
           Status:  Authorized
           Domain:  DATA
   Oper host mode:  multi-auth
  Session timeout:  28800s (server), Remaining: 28027s
   Timeout action:  Reauthenticate
Common Session ID:  0A32010A0000002E6DFC3ACE
  Acct Session ID:  0x00000024
   Current Policy:  PMAP_DefaultWiredDot1xClosedAuth_1X_MAB

Server Policies: Vlan Group: Vlan: 100 ACS ACL: xACSACLx-IP-DACL_ADMIN_FULL-696eef58

Method status list: Method State dot1x Authc Success mab Stopped

====== dACL pushed by ISE

[source,console]

LAB-3560CX-01# show access-list xACSACLx-IP-DACL_ADMIN_FULL-696eef58

Extended IP access list xACSACLx-IP-DACL_ADMIN_FULL-696eef58 (per-user) 2 permit ip any any

====== ISE authorization policy matched

The dACL `DACL_ADMIN_FULL` maps to `Domus_Admin_Profile`. From the OpenAPI policy hierarchy:

[source,console]

Policy Set: Domus_8021X Domus_Cert_Admin_P16g → Domus_Admin_Profile ← THIS ONE (CN match) Domus_Cert_Admins → Domus_Admin_Profile Domus_Cert_Infra_Specific → Domus_Admin_Profile

The authz rule `Domus_Cert_Admin_P16g` matched the certificate CN `modestus-p16g.inside.domusdigitalis.dev` and pushed:

* VLAN 100 (management)
* dACL: `permit ip any any` (full admin access)
* Session timeout: 8 hours with reauthenticate

====== RADIUS server stats

[source,console]

LAB-3560CX-01# show aaa servers

RADIUS: id 2, priority 2, host 10.50.1.20 (ise-01), auth-port 1812, acct-port 1813 State: current UP Authen: request 67, accept 37, reject 0, challenge 30 Account: request 117, start 23, stop 22

Server group ISE-RADIUS: Server(10.50.1.20:1812,1813): Authen 37, Author 3, Acct 117 Server(10.50.1.21:1812,1813): Authen 0 (ise-02 — decommissioned)

====== dot1x template bound ports

[source,console]

Template: DefaultWiredDot1xClosedAuth BOUND: Gi1/0/2 Gi1/0/3 Gi1/0/4 Gi1/0/5 Gi1/0/7 Gi1/0/8 All ports: PAE = AUTHENTICATOR, Sysauthcontrol = Enabled

===== ISE MnT Discovery: Wired Sessions Not in ActiveList

The MnT `ActiveList` API returned only wireless sessions (NAS 10.50.1.40 = WLC). The wired session through the 3560CX (10.50.1.10) does not appear despite the switch confirming RADIUS success and dACL push.

MnT `auth-history` by MAC also returned empty for the wired MAC. This may be a MnT indexing limitation for wired NADs or a delay in the MnT database.

The switch-side `show access-session` is the authoritative source for wired 802.1X session state.

===== ISE MnT API — Key Discoveries

[cols="1,2"]
|===
| Discovery | Detail

| MnT returns XML only
| HTTP 406 on `Accept: application/json` — use `application/xml` + `xq '.'` for JSON conversion

| CoA uses GET not POST
| POST returns 405. GET with PSN short hostname works.

| PSN must be short hostname
| `ise-01` not `ise-01.inside.domusdigitalis.dev` — from `acs_server` field in session detail

| ISE error 34110
| "Incorrect PSN information" = you passed NAS IP or FQDN where ISE expected PSN short hostname

| ActiveList may exclude wired
| Wired sessions through 3560CX not visible in MnT ActiveList — use `show access-session` on switch
|===

===== Switch Commands Quick Reference (IBNS 2.0)

[source,console]

! Session status show access-session show access-session interface gi1/0/3 details

! dACL pushed by ISE show access-list | include xACSACL show access-list xACSACLx-IP-DACL_ADMIN_FULL-696eef58

! dot1x state show dot1x all show dot1x interface gi1/0/3

! Template bindings show template DefaultWiredDot1xClosedAuth

! RADIUS health show aaa servers show radius server-group all

! Port config show run int gi1/0/3

===== Next Steps

1. Test EAP-TEAP on the same port — swap wpa_supplicant config
2. Compare ISE auth logs: EAP-TLS vs EAP-TEAP via DataConnect
3. Remove ise-02 (10.50.1.21) from RADIUS server config — decommissioned
4. Investigate MnT ActiveList wired session gap

===== Utility Scripts Created

.nmcli connection status
[source,bash]

bash $(find ~/atelier -name 'nmcli-status.sh' -path '/infra/')

.Network path discovery (alternative to lldpctl and arp)
[source,bash]

Gateway MAC (replaces deprecated arp command)

ip neigh show dev enp134s0

Switch identity via LLDP/CDP capture

sudo tcpdump -i enp134s0 -nn -vv -c 1 \ 'ether proto 0x88cc or (ether[12:2] == 0x2000)' -l

=== Personal Infrastructure

==== P16g Lid-Wake Black Screen — Evidence Gathering

Recurring issue: laptop wakes from lid-open but internal display stays black. External monitor shows hyprlock and works. Third consecutive day observed (2026-06-19, 2026-06-20, 2026-06-21).

.Root cause (from CR-2026-06-19-p16g-hyprlock-lid-wake)
The lid switch bindings use `hyprctl keyword monitor "eDP-1, disable"` which tears down the i915 DRM display pipe on Arrow Lake-S. The driver cannot reliably re-initialize it on lid open. `hypridle` already avoids this pattern by using `brightnessctl` instead.

.Status
* [x] CR exists: xref:case-studies/changes/CR-2026-06-19-p16g-hyprlock-lid-wake/index.adoc[CR-2026-06-19]
* [x] Evidence partial added to CR — 6 evidence items documenting the broken state
* [ ] Run evidence commands to capture live proof before applying fix
* [ ] Apply fix (Phase 1-3 from implementation partial)
* [ ] Run verification tests (5 functional tests from verification partial)

.Evidence commands to run
[source,bash]

1. Confirm broken bindings

grep -n 'Lid Switch' ~/.config/hypr/hyprland.conf

2. Confirm hypridle uses the correct strategy

awk '/brightnessctl|monitor|dpms/' ~/.config/hypr/hypridle.conf

3. Check i915 errors after a failed wake

sudo dmesg | grep -i 'i915\|drm' | tail -20

4. Check eDP-1 status

hyprctl monitors | awk '/^Monitor|disabled/'

5. Check for WAYLAND-1 phantom

hyprctl monitors -j | jq '.[].name'

.Apply fix (after evidence confirmed)
[source,bash]

Backup

cp ~/.config/hypr/hyprland.conf ~/.config/hypr/hyprland.conf.bak.$(date +%F-%H%M%S)

Before

grep -n 'Lid Switch' ~/.config/hypr/hyprland.conf

Change

sed -i '/switch:on:Lid Switch/s|exec, hyprctl keyword monitor "eDP-1, disable"|exec, loginctl lock-session \&\& brightnessctl -s set 0|' ~/.config/hypr/hyprland.conf sed -i '/switch:off:Lid Switch/s|exec, hyprctl keyword monitor "eDP-1, preferred, auto, 2"|exec, brightnessctl -r|' ~/.config/hypr/hyprland.conf

After

grep -n 'Lid Switch' ~/.config/hypr/hyprland.conf

Reload

hyprctl reload

==== SSH Config — Activate Modular Architecture

Discovered dangling symlink: `~/.ssh/config` points to `dots-quantum/ssh/.ssh/config` which doesn't exist. The restructured config was built yesterday as `config.new` but never renamed.

===== What was built (2026-06-20)

Monolithic SSH config replaced with modular `Include`-based architecture:

config → global defaults + Include config.d/* config.d/00-exceptions.conf → legacy cipher overrides (Azure) — LOADS FIRST config.d/10-git-services.conf → GitHub, GitLab, Codeberg, Bitbucket config.d/20-d000-key-hierarchy.conf → shared key chain (no IPs) config.d/30-cisco-defaults.conf → Cisco/ISE auth (password-only, per RCA-2026-03-16-002) config.d/40-k3s-defaults.conf → k3s auth settings config.d/50-vm-defaults.conf → VM auth settings config.d/80-d000-hosts.conf → host→IP mappings (age-encrypted in git)

This fixes the RCA root cause structurally — `00-exceptions.conf` loads before `Host *` by sort order. Ordering can't break again.

===== Initial state (broken — two issues discovered)

[source,bash]

Issue 1: Symlink was dangling — config.new not yet renamed to config

ls -la ~/.ssh/config # → ../atelier/_projects/personal/dots-quantum/ssh/.ssh/config (MISSING)

ls ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.new # → 11K, modified 2026-06-20

Issue 2 (discovered during fix): 80-d000-hosts.conf was EMPTY

The .age file existed but the host→IP mappings were never migrated

from the old monolithic config into the new modular structure.

grep -i 'kvm-01' ssh/.ssh/config.d/80-d000-hosts.conf # → (no output — file exists but contains no host entries)

Host names existed in key-hierarchy but had no IP mappings

grep -r 'kvm-01' ssh/.ssh/config.d/ # ssh/.ssh/config.d/20-d000-key-hierarchy.conf:Host …​ kvm-01 kvm-02 …​

Result: SSH can’t resolve any custom hosts

ssh kvm-01 # ssh: Could not resolve hostname kvm-01: Name or service not known

===== Fix — four steps

.Step 1: Rename config.new → config (restores the dangling symlink)
[source,bash]

mv ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.new \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config

Verify symlink resolves

file ~/.ssh/config

.Step 2: Populate 80-d000-hosts.conf from DNS (full 45-host zone)
[source,bash]

Preview first — verify IPs are correct

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} vyos-vip bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do ip=$(dig +short ${name}.inside.domusdigitalis.dev @10.50.1.90) [ -n "$ip" ] && printf "Host %s\n HostName %s\n\n" "$name" "$ip" done

Write to file (>| forces overwrite if noclobber is set)

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} vyos-vip bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do ip=$(dig +short ${name}.inside.domusdigitalis.dev @10.50.1.90) [ -n "$ip" ] && printf "Host %s\n HostName %s\n\n" "$name" "$ip" done >| ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

.Step 3: Verify ALL hosts resolve to IPs (not just aliases)
[source,bash]

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} vyos-vip \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do ip=$(ssh -G "$name" | awk '/^hostname/{print $2}') if [[ "$ip" == "$name" ]]; then printf "BAD %-20s ← alias echoed back, no mapping found\n" "$name" else printf "GOOD %-20s → %s\n" "$name" "$ip" fi done

.Expected output (all GOOD — hosts that resolve from DNS)

GOOD kvm-01 → 10.50.1.110 GOOD kvm-02 → 10.50.1.111 GOOD nas-01 → 10.50.1.70 GOOD nas-02 → 10.50.1.71 GOOD vyos-01 → 10.50.1.2 GOOD vyos-02 → 10.50.1.3 GOOD bind-01 → 10.50.1.90 GOOD bind-02 → 10.50.1.91 GOOD ipa-01 → 10.50.1.100 GOOD ipa-02 → 10.50.1.101 GOOD keycloak-01 → 10.50.1.80 GOOD keycloak-02 → 10.50.1.81 GOOD ipsk-mgr-01 → 10.50.1.30 GOOD vault-01 → (IP from DNS) GOOD vault-02 → (IP from DNS) GOOD vault-03 → (IP from DNS) GOOD home-dc01 → (IP from DNS) GOOD 9800-WLC-01 → (IP from DNS) GOOD 9800-WLC-02 → (IP from DNS) GOOD k3s-master-01 → (IP from DNS) GOOD ise-01 → (IP from DNS) GOOD certmgr-01 → (IP from DNS)

NOTE: `ssh -G` reads the config and prints resolved values WITHOUT connecting. If any host shows BAD, its entry is missing from `80-d000-hosts.conf`.

.Step 4: Test connection
[source,bash]

ssh kvm-01

===== Post-activation — encrypt and commit

After verifying `ssh -G kvm-01 | awk '/^hostname/'` returns an IP:

.Step 1: Re-encrypt 80-d000-hosts.conf (delete stale .age first)
[source,bash]

Delete stale .age FIRST — old .age silently overrides edits

rm ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf.age

Encrypt the generated host mappings

age -e -r "$(cat ~/.secrets/.metadata/keys/master.age.pub)" \ -o ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf.age \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

.Step 2: Encrypt the main config
[source,bash]

rm ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.age

age -e -r "$(cat ~/.secrets/.metadata/keys/master.age.pub)" \ -o ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.age \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config

.Step 3: Commit to dots-quantum
[source,bash]

git -C ~/atelier/_projects/personal/dots-quantum add \ ssh/.ssh/config.age \ ssh/.ssh/config.d/80-d000-hosts.conf.age

git -C ~/atelier/_projects/personal/dots-quantum commit -m "$(cat <<'EOF' chore(ssh): encrypt modular config v4.0 + generated host mappings

80-d000-hosts.conf populated from DNS via dig loop against BIND primary (10.50.1.90). 45 A records, full zone coverage. EOF )"

.Step 4: Verify round-trip (decrypt and diff)
[source,bash]

Confirm the .age decrypts back to the same content

diff <(age -d -i ~/.secrets/.metadata/keys/master.age.key ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf.age) \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf # No output = identical

===== Fix: Network device patterns missing from 30-cisco-defaults.conf

`9800-wlc-01` SSH failed with `Permission denied` — the host matched `80-d000-hosts.conf` (IP resolved) but fell through to `Host *` (PasswordAuthentication no) because `30-cisco-defaults.conf` only matched `wlc-*`, not `9800-wlc-*`.

.Before
[source,bash]

grep -n 'Host cisco' $(find -name '30-cisco-defaults.conf') # 22:Host cisco-* switch-* router-* fw-* wlc-* ap-*

.Change — add all network device prefixes from zone dump
[source,bash]

sed -i 's/Host cisco-\* switch-\* router-\* fw-\* wlc-\* ap-*/Host cisco-* switch-* router-* fw-* wlc-* 9800-wlc-* 9800-* 9300-* 3560cx-* ap-*/' \ $(find -name '30-cisco-defaults.conf')

.After
[source,bash]

grep -n 'Host cisco' $(find -name '30-cisco-defaults.conf') # 22:Host cisco-* switch-* router-* fw-* wlc-* 9800-wlc-* 9800-* 9300-* 3560cx-* ap-*

.Verify — should show user=admin, passwordauthentication=yes
[source,bash]

ssh -G 9800-wlc-01 | awk '/^user |^passwordauthentication /' ssh -G 3560cx-01 | awk '/^user |^passwordauthentication /' ssh -G 9300-01 | awk '/^user |^passwordauthentication /'

===== Why this happened

Not a design failure. The architecture is correct — modular `Include`, numbered sort order, encrypted host mappings. Three things were left unfinished during the migration:

1. `config.new` was never renamed to `config` — dangling symlink
2. `80-d000-hosts.conf` was empty — host→IP mappings were never migrated from the old monolithic config
3. Host names existed in `20-d000-key-hierarchy.conf` (key assignment) but had no corresponding `HostName` entries with IPs

The fix used a `dig` loop to generate the host mappings directly from DNS — turning the BIND zone data into SSH config entries. This is better than manually copying IPs from the old config because it's verifiable against the authoritative source.

===== Setup on a new machine (from `git pull`)

The entire SSH config is portable via `dots-quantum`. On any new machine:

[source,bash]

1. Clone dots-quantum (if not already)

git clone git@github.com:EvanusModestus/dots-quantum.git \ ~/atelier/_projects/personal/dots-quantum

2. Create required directories

mkdir -p ~/.ssh/{sockets,config.d} && chmod 700 ~/.ssh ~/.ssh/sockets ~/.ssh/config.d

3. Stow the SSH module (creates symlinks)

stow -d ~/atelier/_projects/personal/dots-quantum -t ~ ssh

4. Verify symlinks

ls -la ~/.ssh/config ~/.ssh/config.age # Both should point into dots-quantum/ssh/.ssh/

5. Decrypt the main config (plaintext is gitignored — never tracked)

age -d -i ~/.secrets/.metadata/keys/master.age.key ~/.ssh/config.age > \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config

6. Generate host→IP mappings from DNS

(80-d000-hosts.conf is generated, not decrypted — IPs come from BIND)

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} vyos-vip \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do ip=$(dig +short ${name}.inside.domusdigitalis.dev @10.50.1.90) [ -n "$ip" ] && printf "Host %s\n HostName %s\n\n" "$name" "$ip" done >| ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

7. Verify

ssh -G kvm-01 | awk '/^hostname/'

.What stow creates

~/.ssh/config → dots-quantum/ssh/.ssh/config (symlink) ~/.ssh/config.age → dots-quantum/ssh/.ssh/config.age (symlink) ~/.ssh/config.d/ → dots-quantum/ssh/.ssh/config.d/ (symlink or individual links)

.What git tracks (only encrypted files)

ssh/.ssh/config.age # main config (encrypted) ssh/.ssh/config.d/00-exceptions.conf # plaintext — no secrets ssh/.ssh/config.d/10-git-services.conf # plaintext — no secrets ssh/.ssh/config.d/20-d000-key-hierarchy.conf # plaintext — no secrets ssh/.ssh/config.d/30-cisco-defaults.conf # plaintext — no secrets ssh/.ssh/config.d/40-k3s-defaults.conf # plaintext — no secrets ssh/.ssh/config.d/50-vm-defaults.conf # plaintext — no secrets ssh/.ssh/config.d/80-d000-hosts.conf.age # encrypted — contains IPs

.What is gitignored (plaintext with secrets)

ssh/.ssh/config # decrypted main config ssh/.ssh/config.d/80-d000-hosts.conf # decrypted host→IP mappings

.Prerequisites on the new machine
* `age` installed — for decryption
* `~/.age/identities` — your age private key (never in git, transfer via secure channel)
* `stow` installed — for symlink management
* `openssh >= 9.0` — for Post-Quantum KEX support

The pattern: **git tracks encrypted files, stow creates symlinks, age decrypts on each machine.** The plaintext never enters git. Every machine gets the same config from one `git pull` + two `age -d` commands.

===== Related

* xref:case-studies/rca/ssh-config-order/index.adoc[RCA-2026-03-16-002: SSH Config Order] — original root cause (Host * before exceptions)
* The modular architecture is the permanent fix for that RCA

==== BIND Essentials — DNS Checks and Automation

Essential BIND commands for zone management, record verification, and health checks. All commands run from the workstation unless noted.

===== Zone Dump — See EVERYTHING in DNS

The authoritative way to find every host. No guessing, no loops — the zone file is the source of truth.

.Full zone transfer (AXFR) from primary
[source,bash]

dig axfr inside.domusdigitalis.dev @10.50.1.90

.Extract just A records — clean table, trailing dot stripped, sorted by IP
[source,bash]

dig +noall +answer axfr inside.domusdigitalis.dev @localhost | \ awk '$4=="A" { h=$1; sub(/\.$/,"",h) printf "%-45s %s\n", h, $5 }' | sort -k2,2V -k1,1

Why this version is better than `awk '/\tA\t/'`:

* `+noall +answer` — suppresses headers, only answer section (cleaner input to awk)
* `$4=="A"` — exact field match on record type, not a regex that could false-positive on AAAA or TXT containing "A"
* `sub(/\.$/,"",h)` — strips the trailing dot from FQDNs (DNS wire format → human-readable)
* `sort -k2,2V` — version sort on IP field (10.50.1.2 before 10.50.1.10, not lexicographic)

.Count total A records
[source,bash]

dig +noall +answer axfr inside.domusdigitalis.dev @localhost | awk '$4=="A"' | wc -l

.Zone dump output (2026-06-21 — 45 A records from bind-02)

3560cx-01.inside.domusdigitalis.dev 10.50.1.10 9300-01.inside.domusdigitalis.dev 10.50.1.11 9800-wlc-01.inside.domusdigitalis.dev 10.50.1.40 9800-wlc-02.inside.domusdigitalis.dev 10.50.1.41 alertmanager.inside.domusdigitalis.dev 10.50.1.131 bind-01.inside.domusdigitalis.dev 10.50.1.90 bind-02.inside.domusdigitalis.dev 10.50.1.91 certmgr-01.inside.domusdigitalis.dev 10.50.1.63 gitea-01.inside.domusdigitalis.dev 10.50.1.70 grafana.inside.domusdigitalis.dev 10.50.1.131 home-dc01.inside.domusdigitalis.dev 10.50.1.50 home-dc02.inside.domusdigitalis.dev 10.50.1.51 ipa-01.inside.domusdigitalis.dev 10.50.1.100 ipa-02.inside.domusdigitalis.dev 10.50.1.101 ipmi-01.inside.domusdigitalis.dev 10.50.1.200 ipmi-02.inside.domusdigitalis.dev 10.50.1.201 ipsk-mgr-01.inside.domusdigitalis.dev 10.50.1.30 ipsk-mgr-02.inside.domusdigitalis.dev 10.50.1.31 ise-01.inside.domusdigitalis.dev 10.50.1.20 ise-02.inside.domusdigitalis.dev 10.50.1.21 k3s-master-01.inside.domusdigitalis.dev 10.50.1.120 k3s-master-02.inside.domusdigitalis.dev 10.50.1.121 k3s-master-03.inside.domusdigitalis.dev 10.50.1.122 keycloak-01.inside.domusdigitalis.dev 10.50.1.80 keycloak-02.inside.domusdigitalis.dev 10.50.1.81 kvm-01.inside.domusdigitalis.dev 10.50.1.110 kvm-02.inside.domusdigitalis.dev 10.50.1.111 modestus-aw.inside.domusdigitalis.dev 10.50.40.102 modestus-razer.inside.domusdigitalis.dev 10.50.10.130 nas-01.inside.domusdigitalis.dev 10.50.1.70 nas-02.inside.domusdigitalis.dev 10.50.1.71 prometheus.inside.domusdigitalis.dev 10.50.1.131 vault-01.inside.domusdigitalis.dev 10.50.1.60 vault-02.inside.domusdigitalis.dev 10.50.1.61 vault-03.inside.domusdigitalis.dev 10.50.1.62 vyos-01.inside.domusdigitalis.dev 10.50.1.2 vyos-02.inside.domusdigitalis.dev 10.50.1.3 vyos-vip.inside.domusdigitalis.dev 10.50.1.1 wazuh-api.inside.domusdigitalis.dev 10.50.1.134 wazuh-dashboard.inside.domusdigitalis.dev 10.50.1.133 wazuh-indexer.inside.domusdigitalis.dev 10.50.1.130 wazuh-workers.inside.domusdigitalis.dev 10.50.1.134 wazuh.inside.domusdigitalis.dev 10.50.1.132 wlc-01.inside.domusdigitalis.dev 10.50.1.40 wlc-02.inside.domusdigitalis.dev 10.50.1.41

NOTE: AXFR must be allowed from your IP in BIND's `named.conf`. If it fails with `Transfer failed`, you need to run it from bind-01/bind-02 directly or add your workstation IP to the `allow-transfer` ACL.

===== On-Server Commands (run via SSH)

.Check zone file syntax before reload
[source,bash]

ssh bind-01 sudo named-checkzone inside.domusdigitalis.dev /var/named/inside.domusdigitalis.dev.zone

.Check named.conf syntax
[source,bash]

ssh bind-01 sudo named-checkconf

.Reload zone after edits (no restart needed)
[source,bash]

ssh bind-01 sudo rndc reload inside.domusdigitalis.dev

.Check zone serial numbers (primary vs secondary)
[source,bash]

paste <(dig SOA inside.domusdigitalis.dev @10.50.1.90 +short | awk '{print "primary:", $3}') \ <(dig SOA inside.domusdigitalis.dev @10.50.1.91 +short | awk '{print "secondary:", $3}')

Same serial = zone transfer is current. Different = stale secondary.

.Force zone transfer to secondary
[source,bash]

ssh bind-01 sudo rndc notify inside.domusdigitalis.dev

===== Health Checks (from workstation)

.Verify both servers respond
[source,bash]

for dns in 10.50.1.90 10.50.1.91; do printf "%-15s %s\n" "$dns" "$(dig +short +time=2 kvm-01.inside.domusdigitalis.dev @${dns} 2>&1)" done

.Check for stale/missing PTR records (reverse DNS)
[source,bash]

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} vyos-vip \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do ip=$(dig +short ${name}.inside.domusdigitalis.dev @10.50.1.90) if [ -n "$ip" ]; then ptr=$(dig +short -x "$ip" @10.50.1.90) if [ -z "$ptr" ]; then printf "MISSING PTR %-20s %s\n" "$name" "$ip" else printf "OK %-20s %s → %s\n" "$name" "$ip" "$ptr" fi else printf "NO A RECORD %-20s\n" "$name" fi done

.Compare SSH config hosts against DNS (find gaps)
[source,bash]

Hosts in SSH config that don’t resolve

for name in $(awk '/^Host /{for(i=2;i⇐NF;i++) print $i}' \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf); do ip=$(dig +short ${name}.inside.domusdigitalis.dev @10.50.1.90) [ -z "$ip" ] && printf "SSH config has %-20s but DNS does not\n" "$name" done

DNS A records not in SSH config

diff <(dig axfr inside.domusdigitalis.dev @10.50.1.90 | awk '/\tA\t/{gsub(/\.$/,"",$1); print $1}' | sort) \ <(awk '/^Host /{for(i=2;i⇐NF;i++) print $i".inside.domusdigitalis.dev"}' \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf | sort) \ | grep '^<' | sed 's/^< /DNS only: /'

===== Service Status (via SSH)

[source,bash]

for host in bind-0{1,2}; do printf "\n=== %s ===\n" "$host" ssh "$host" systemctl is-active named ssh "$host" sudo rndc status 2>&1 | awk '/version|server is up|number of zones/' done

===== Quick Reference

[cols="1,2"]
|===
| Task | Command

| Resolve a host
| `dig +short hostname.inside.domusdigitalis.dev @10.50.1.90`

| Reverse lookup
| `dig +short -x 10.50.1.110 @10.50.1.90`

| Full zone dump
| `dig axfr inside.domusdigitalis.dev @10.50.1.90`

| Zone syntax check
| `ssh bind-01 sudo named-checkzone zone /path/to/zone.file`

| Config syntax check
| `ssh bind-01 sudo named-checkconf`

| Reload zone
| `ssh bind-01 sudo rndc reload inside.domusdigitalis.dev`

| Force zone transfer
| `ssh bind-01 sudo rndc notify inside.domusdigitalis.dev`

| Check serial match
| `dig SOA zone @primary +short` vs `dig SOA zone @secondary +short`

| Service status
| `ssh bind-01 systemctl is-active named`
|===

==== Age Key Mismatch — Two Keypairs, Wrong One Used

Discovered during SSH config activation: `age -e -R ~/.age/recipients/self.txt` encrypts to a DIFFERENT keypair than `encrypt-file`/`decrypt-file` use.

===== The Two Keypairs

[cols="1,2,2"]
|===
| Keypair | Public Key (truncated) | Used By

| **Personal**
| `age1u2hgwz...xcr3l`
| `~/.age/recipients/self.txt`, manual `age -e -R` commands

| **Master**
| `age1wtdeue...jqcnn`
| `encrypt-file`, `decrypt-file`, `d001 open/close`
|===

.Key file locations

Personal keypair

~/.age/identities/personal.key (private) ~/.age/identities/personal.pub (public) ~/.age/recipients/self.txt (public — SAME as personal.pub)

Master keypair

~/.secrets/.metadata/keys/master.age.key (private) ~/.secrets/.metadata/keys/master.age.pub (public)

===== What Went Wrong

The ssh-config-activation partial used:
[source,bash]

WRONG — encrypts to personal key, but decrypt-file uses master key

age -e -R ~/.age/recipients/self.txt -o file.age file

`decrypt-file` then fails with `no identity matched any of the recipients` because it uses `master.age.key` which is a different keypair.

===== The Rule

**Always use `encrypt-file`/`decrypt-file`** — they use the correct key (`master.age.key`/`master.age.pub`).

If you MUST use raw `age` commands (e.g., no access to the scripts):

[source,bash]

Encrypt (raw) — use master.age.pub, NOT self.txt

age -e -r "$(cat ~/.secrets/.metadata/keys/master.age.pub)" -o file.age file

Decrypt (raw) — use master.age.key

age -d -i ~/.secrets/.metadata/keys/master.age.key -o file file.age

NEVER use `-R ~/.age/recipients/self.txt` — that encrypts to the personal key, which `decrypt-file` cannot open.

===== How to Verify Key Match

[source,bash]

Derive public from each private

echo "=== personal ===" && age-keygen -y ~/.age/identities/personal.key echo "=== master ===" && age-keygen -y ~/.secrets/.metadata/keys/master.age.key echo "=== self.txt ===" && cat ~/.age/recipients/self.txt

If `self.txt` matches `personal` but your tools use `master` — that's the mismatch.

===== Open Question

Should `self.txt` be updated to point to the master key? That would align manual `age -e -R self.txt` with `encrypt-file`. But it would break decryption of any files previously encrypted to the personal key. Needs a full audit of which files are encrypted to which key before changing.

[source,bash]

Audit: which .age files can master decrypt? (exits 0 = success, 1 = wrong key)

find ~/atelier -name '*.age' -exec sh -c ' age -d -i ~/.secrets/.metadata/keys/master.age.key "$1" > /dev/null 2>&1 \ && echo "MASTER: $1" \ || echo "PERSONAL: $1" ' _ {} \;

===== Automated Verification — `find` + Process Substitution

No hardcoded paths. `find` locates every file dynamically:

.Round-trip verification (decrypt .age, diff against plaintext)
[source,bash]

diff <(age -d -i $(find ~ -name 'master.age.key') \ $(find -name '80-d000-hosts.conf.age')) \ $(find -name '80-d000-hosts.conf' -not -name '*.age') # No output = identical

.SSH host resolution check
[source,bash]

ssh -G kvm-01 | awk '/^hostname/' # hostname 10.50.1.110 = working

This pattern works from ANY directory — `find` resolves the paths, process substitution feeds them to `diff` and `age`. No `cd`, no hardcoded paths, no assumptions about working directory.

===== Commands to Diagnose (reference)

[source,bash]

List all age keys

find ~/.age ~/.secrets -name '.key' -o -name '.age.key' 2>/dev/null

List all age public keys / recipients

find ~/.age ~/.secrets -name '.pub' -o -name 'self.txt' -o -name 'recipients' 2>/dev/null

Derive public from private (safe — only shows public key)

age-keygen -y /path/to/private.key

Test decryption without writing output

age -d -i /path/to/key -o /dev/null file.age

// Concern: gopass v3 store — gitea remote hostname mismatch fix
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/gopass-gitea-remote-fix.adoc

==== gopass v3 — Gitea Remote Hostname Fix

`gopass sync` failed pushing to the gitea remote:

[source,console]

ssh: Could not resolve hostname gitea: Name or service not known fatal: Could not read from remote repository.

===== Root Cause

The v3 store's `origin` had three push URLs. The gitea URL used bare `gitea` as the hostname, but SSH config defines `Host gitea-01` (not `gitea`):

.Before — broken
[source,console]

$ git -C ~/.local/share/gopass/stores/v3 remote -v origin git@github.com:EvanusModestus/gopass-v3.git (fetch) origin git@github.com:EvanusModestus/gopass-v3.git (push) origin git@gitlab.com:EvanusModestus/gopass-v3.git (push) origin git@gitea:evanusmodestus/gopass-v3.git (push)

SSH config (`~/.ssh/config.d/80-d000-hosts.conf`) has `Host gitea-01` pointing to `10.50.1.70` — no `Host gitea` alias exists.

===== Fix Applied

Replace the broken push URL with one matching the SSH config hostname:

[source,bash]

Show all push URLs

git -C ~/.local/share/gopass/stores/v3 remote get-url --push --all origin

Remove the broken gitea URL

git -C ~/.local/share/gopass/stores/v3 remote set-url --delete --push origin \ 'git@gitea:evanusmodestus/gopass-v3.git'

Add corrected URL matching SSH config Host

git -C ~/.local/share/gopass/stores/v3 remote set-url --add --push origin \ 'git@gitea-01:evanusmodestus/gopass-v3.git'

Verify

git -C ~/.local/share/gopass/stores/v3 remote -v

.After — fixed
[source,console]

$ git -C ~/.local/share/gopass/stores/v3 remote -v origin git@github.com:EvanusModestus/gopass-v3.git (fetch) origin git@github.com:EvanusModestus/gopass-v3.git (push) origin git@gitlab.com:EvanusModestus/gopass-v3.git (push) origin git@gitea-01:evanusmodestus/gopass-v3.git (push)

===== Why Option A Over Option B

Adding an SSH alias (`Host gitea → 10.50.1.70`) would also fix the resolution, but creates a second name for the same host. The SSH config already uses `gitea-01` consistently — a bare `gitea` alias introduces naming drift that surfaces later as a "which hostname is correct?" debugging session.

Fix the remote URL to match the existing SSH config. Don't add SSH aliases to paper over inconsistent git remotes.

===== Phase 2 — SSH Key for Gitea

After fixing the remote URL, `gopass sync` connected to `10.50.1.70` but got `Permission denied (publickey)`. The SSH config for `gitea-01` had no `IdentityFile` — SSH tried default key names, none of which exist. The gitea key is `~/.ssh/id_ed25519_gitea`.

.Diagnose — see which keys SSH offers
[source,bash]

ssh -T -v git@gitea-01 2>&1 | grep -E 'Offering|Authenticated|identity'

.Step 1: Verify before — current state
[source,bash]

grep -A3 'Host gitea-01' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

Expected: `Host gitea-01` with `HostName 10.50.1.70`, no `IdentityFile`.

.Step 2: Change — add IdentityFile via sed
[source,bash]

sed -i '/^Host gitea-01$/,/^$/{/HostName/a\ IdentityFile ~/.ssh/id_ed25519_gitea }' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

.Step 3: Verify after — confirm the insertion
[source,bash]

grep -A4 'Host gitea-01' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

Expected:

Host gitea-01 HostName 10.50.1.70 IdentityFile ~/.ssh/id_ed25519_gitea

.Step 4: Test SSH — confirm the key is offered
[source,bash]

ssh -T -v git@gitea-01 2>&1 | grep -E 'Offering|Authenticated'

Should show `Offering public key: /home/evanusmodestus/.ssh/id_ed25519_gitea` and `Authenticated`.

===== Phase 3 — SSH Port Mismatch (2222 vs 22)

After fixing hostname and key, `gopass sync` still failed with `Permission denied`. The key was offered but gitea rejected it — because gitea's Docker SSH listener runs on port `2222`, not the default `22`.

.Diagnose — query gopass for the SSH port
[source,bash]

gopass show v3/domains/d000/cloud/gitea/evanusmodestus | sed '1,/^---$/d' | yq -r '.url, .ssh_port'

Output:
The git remote `git@gitea-01:...` uses port 22. Gitea listens on 2222.

.Diagnostic trail — gopass entries queried to find the port
[source,bash]

List all gitea-related entries

gopass ls --flat | grep -i gitea

Check server entry for keys

gopass show v3/domains/d000/servers/gitea/gitea-01 | sed '1,/^---$/d' | yq 'keys'

Check cloud/account entry for keys

gopass show v3/domains/d000/cloud/gitea/evanusmodestus | sed '1,/^---$/d' | yq 'keys'

Check SSH key entry for keys

gopass show v3/domains/d000/identity/ssh/gitea | sed '1,/^---$/d' | yq 'keys'

Extract URL and SSH port

gopass show v3/domains/d000/cloud/gitea/evanusmodestus | sed '1,/^---$/d' | yq -r '.url, .ssh_port'

.Step 1: Verify before — current SSH config
[source,bash]

grep -A4 'Host gitea-01' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

.Step 2: Change — add Port 2222 via sed
[source,bash]

sed -i '/^Host gitea-01$/,/^$/{/IdentityFile/a\ Port 2222 }' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

.Step 3: Verify after — confirm the insertion
[source,bash]

grep -A5 'Host gitea-01' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

Expected:

Host gitea-01 HostName 10.50.1.70 IdentityFile ~/.ssh/id_ed25519_gitea Port 2222

.Step 4: Test SSH on port 2222
[source,bash]

ssh -T git@gitea-01

.Step 5: Update git remote URL — SSH with non-standard port uses `ssh://` syntax
[source,bash]

View current

git -C ~/.local/share/gopass/stores/v3 remote get-url --push --all origin

Remove port-22 URL

git -C ~/.local/share/gopass/stores/v3 remote set-url --delete --push origin \ 'git@gitea-01:evanusmodestus/gopass-v3.git'

Add ssh:// URL with port 2222

git -C ~/.local/share/gopass/stores/v3 remote set-url --add --push origin \ 'ssh://git@gitea-01:2222/evanusmodestus/gopass-v3.git'

Verify

git -C ~/.local/share/gopass/stores/v3 remote -v

NOTE: `git@host:path` syntax always uses port 22. For non-standard ports, you must use `ssh://git@host:port/path` syntax.

.Step 6: Test gopass sync — confirm all three remotes push
[source,bash]

gopass sync

All three push targets (GitHub, GitLab, Gitea) should succeed.

.Step 7: Encrypt — delete stale .age, re-encrypt, verify round-trip
[source,bash]

Delete stale .age FIRST — old .age silently overrides edits

rm ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf.age

Re-encrypt

age -e -r "$(cat ~/.secrets/.metadata/keys/master.age.pub)" \ -o ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf.age \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

Verify round-trip — no output means identical

diff <(age -d -i ~/.secrets/.metadata/keys/master.age.key \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf.age) \ ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

.Step 7: Commit — only the .age file
[source,bash]

git -C ~/atelier/_projects/personal/dots-quantum add \ ssh/.ssh/config.d/80-d000-hosts.conf.age

git -C ~/atelier/_projects/personal/dots-quantum commit -m "$(cat <<'EOF' chore(ssh): add IdentityFile for gitea-01 in 80-d000-hosts

Fixes gopass sync push to gitea — SSH was not offering id_ed25519_gitea because no IdentityFile was specified. EOF )"

===== Verification

[source,bash]

Test SSH key

ssh -T git@gitea-01

Test gopass sync

gopass sync

All three push targets (GitHub, GitLab, Gitea) should succeed.

===== Commands Learned

[cols="1,2"]
|===
| Command | What It Does

| `git remote get-url --push --all origin`
| List all push URLs for a remote — reveals multi-push configurations

| `git remote set-url --delete --push origin 'url'`
| Remove a specific push URL without affecting fetch or other push URLs

| `git remote set-url --add --push origin 'url'`
| Add a new push URL — origin pushes to multiple remotes

| `git -C path`
| Run git in a different directory without `cd` — cleaner than `cd && git && cd -`
|===

// Concern: Multi-remote git push — add GitLab + Gitea to all repos, parallel push
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/multi-remote-push.adoc

==== Multi-Remote Git Push — GitHub + GitLab + Gitea

===== Add Push URLs to a Single Repo

When `origin` only has GitHub, add GitLab and Gitea as push targets.
The first `--add --push` must re-add GitHub — creating an explicit push list replaces the implicit fetch-as-push.

.Verify before
[source,bash]

git -C ~/atelier/_projects/personal/dots-quantum remote -v

.Add all three push URLs
[source,bash]

git -C ~/atelier/_projects/personal/dots-quantum remote set-url --add --push origin \ 'git@github.com:EvanusModestus/dots-quantum.git' git -C ~/atelier/_projects/personal/dots-quantum remote set-url --add --push origin \ 'git@gitlab.com:EvanusModestus/dots-quantum.git' git -C ~/atelier/_projects/personal/dots-quantum remote set-url --add --push origin \ 'ssh://git@gitea-01:2222/evanusmodestus/dots-quantum.git'

.Verify after — should show 1 fetch, 3 push
[source,bash]

git -C ~/atelier/_projects/personal/dots-quantum remote -v

Expected:

origin git@github.com:EvanusModestus/dots-quantum.git (fetch) origin git@github.com:EvanusModestus/dots-quantum.git (push) origin git@gitlab.com:EvanusModestus/dots-quantum.git (push) origin ssh://git@gitea-01:2222/evanusmodestus/dots-quantum.git (push)

.Test push
[source,bash]

git -C ~/atelier/_projects/personal/dots-quantum push origin main

NOTE: Confirm the repo exists on each remote before pushing. For gitea: `https://10.50.1.70:3000/evanusmodestus/dots-quantum`. Create it in the browser if it doesn't exist.

===== Add Push URLs to ALL Repos in a Directory

Loop over every git repo under `~/atelier/` and add the missing push URLs.

.Audit — show which repos only push to GitHub
[source,bash]

for repo in $(find ~/atelier -maxdepth 4 -name '.git' -type d); do dir=$(dirname "$repo") name=$(basename "$dir") count=$(git -C "$dir" remote get-url --push --all origin 2>/dev/null | wc -l) if [[ "$count" -lt 3 ]]; then printf "⚠️ %-30s %d push URL(s)\n" "$name" "$count" else printf "✅ %-30s %d push URL(s)\n" "$name" "$count" fi done

.Add GitLab + Gitea to repos that only have GitHub
[source,bash]

for repo in $(find ~/atelier -maxdepth 4 -name '.git' -type d); do dir=$(dirname "$repo") name=$(basename "$dir") urls=$(git -C "$dir" remote get-url --push --all origin 2>/dev/null)

# Skip if already has 3 push URLs
[[ $(echo "$urls" | wc -l) -ge 3 ]] && continue
# Extract the repo name from the GitHub URL
gh_url=$(echo "$urls" | grep github | head -1)
repo_name=$(basename "$gh_url" .git)
# Skip if no GitHub URL found
[[ -z "$repo_name" ]] && continue
echo "→ Adding remotes to $name ($repo_name)"
# Re-add GitHub (explicit push list replaces implicit)
git -C "$dir" remote set-url --add --push origin \
  "git@github.com:EvanusModestus/${repo_name}.git"
# Add GitLab
git -C "$dir" remote set-url --add --push origin \
  "git@gitlab.com:EvanusModestus/${repo_name}.git"
# Add Gitea (port 2222, lowercase username)
git -C "$dir" remote set-url --add --push origin \
  "ssh://git@gitea-01:2222/evanusmodestus/${repo_name}.git"
  # Verify
  printf "  "; git -C "$dir" remote get-url --push --all origin | wc -l
  echo "  push URLs configured"
done
===== Parallel Push — All Repos at Once

.Push every repo under ~/atelier in parallel
[source,bash]

find ~/atelier -maxdepth 4 -name '.git' -type d -print0 | \ xargs -0 -I{} -P4 sh -c ' dir=$(dirname "$1") name=$(basename "$dir") if git -C "$dir" push origin main 2>&1 | tail -1; then printf "✅ %s\n" "$name" else printf "❌ %s\n" "$name" fi ' _ {}

`-P4` runs 4 pushes in parallel. `-print0`/`-0` handles paths with spaces.

.Push specific repos only — brace expansion
[source,bash]

for repo in dots-quantum domus-{captures,docs,infra-ops,ise-linux,netapi-docs,secrets-ops,antora-ui}; do dir=$(find ~/atelier -maxdepth 4 -type d -name "$repo" | head -1) [ -d "$dir/.git" ] && git -C "$dir" push origin main & done wait

`&` backgrounds each push, `wait` blocks until all finish. Simple parallel without xargs.

.Verify all remotes across all repos
[source,bash]

for repo in $(find ~/atelier -maxdepth 4 -name '.git' -type d); do dir=$(dirname "$repo") name=$(basename "$dir") count=$(git -C "$dir" remote get-url --push --all origin 2>/dev/null | wc -l) printf "%-30s %d remotes\n" "$name" "$count" done | sort -k2 -n

===== Key Rules

[cols="1,2"]
|===
| Rule | Why

| `git@host:path` syntax
| Always uses port 22 — cannot specify a different port

| `ssh://git@host:port/path` syntax
| Required for non-standard SSH ports (gitea on 2222)

| First `--add --push` must re-add the original
| Creating an explicit push list replaces the implicit fetch-as-push URL

| `git -C path` not `cd path && git`
| Cleaner, no directory state to manage, works in loops

| `& + wait` for parallel push
| Simpler than xargs when repos are known by name

| `-P4` xargs for parallel push
| Better when discovering repos via `find`
|===

// Concern: Gitea API — repo creation via curl + dsec + jq
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/gitea-api-repo-creation.adoc

==== Gitea API — Repo Creation from CLI

===== General API Wrapper

Terminal paste breaks multi-line curl. This wrapper script solves it permanently — every API call becomes `bash /tmp/gitea-api.sh <endpoint> | jq <filter>`.

.Create the wrapper
[source,bash]

tee /tmp/gitea-api.sh << 'SCRIPT' #!/bin/bash # Usage: bash /tmp/gitea-api.sh <endpoint> [method] [data] # Examples: # bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq '.[].name' # bash /tmp/gitea-api.sh /repos/evanusmodestus/dots-quantum | jq . # bash /tmp/gitea-api.sh /user | jq '{login, email}' # bash /tmp/gitea-api.sh /user/repos POST '{"name":"new-repo","private":true}'

endpoint="${1:?Usage: gitea-api.sh <endpoint> [method] [data]}" method="${2:-GET}" data="$3"

base="https://gitea-01.inside.domusdigitalis.dev:3000/api/v1"

if [ -n "$data" ]; then curl -sk -X "$method" \ -H "Authorization: token ${GITEA_PAT}" \ -H "Content-Type: application/json" \ -d "$data" \ "${base}${endpoint}" else curl -sk -X "$method" \ -H "Authorization: token ${GITEA_PAT}" \ "${base}${endpoint}" fi SCRIPT chmod +x /tmp/gitea-api.sh

===== General API Queries

.Your profile
[source,bash]

bash /tmp/gitea-api.sh /user | jq '{login, email, created}'

.List all repos — names only
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[].name' | sort

.Query a specific repo
[source,bash]

bash /tmp/gitea-api.sh /repos/evanusmodestus/dots-quantum | jq '{name, ssh_url, clone_url, private, created_at}'

.Get SSH URL for git remote config
[source,bash]

bash /tmp/gitea-api.sh /repos/evanusmodestus/dots-quantum | jq -r '.ssh_url'

.Table format — name, SSH URL, visibility
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[] | [.name, .ssh_url, (if .private then "private" else "public" end)] | @tsv' | sort | column -t

.Count total repos
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq length

.Save full response to file for later inspection
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq . | tee /tmp/gitea-repos.json | jq -r '.[].name' | sort

.Query from saved file (no API call needed)
[source,bash]

jq -r '.[] | select(.private == true) | .name' /tmp/gitea-repos.json jq -r '.[] | select(.name | startswith("domus-")) | .name' /tmp/gitea-repos.json jq '[.[] | .size] | add' /tmp/gitea-repos.json

===== Load Credentials

[source,bash]

Source gitea token from dsec (uses shell wrapper, not eval)

dsource d000 dev/app

Verify keys loaded (names only, no values)

env | grep -i GITEA | awk -F= '{print $1}'

===== Create a Single Repo

Terminal paste breaks multi-line curl. Write to a script file instead.

.Create repo script
[source,bash]

tee /tmp/gitea-create-repo.sh << 'SCRIPT' #!/bin/bash curl -sk -X POST \ -H "Authorization: token ${GITEA_PAT}" \ -H "Content-Type: application/json" \ -d '{"name":"'"$1"'","private":true}' \ "https://gitea-01.inside.domusdigitalis.dev:3000/api/v1/user/repos" SCRIPT chmod +x /tmp/gitea-create-repo.sh

.Usage — create a single repo
[source,bash]

bash /tmp/gitea-create-repo.sh dots-quantum | jq .

.Parse the response with jq
[source,bash]

Full response — see all fields

bash /tmp/gitea-create-repo.sh dots-quantum | jq .

Just the essentials

bash /tmp/gitea-create-repo.sh dots-quantum | jq '{name, full_name, ssh_url, clone_url, html_url, private}'

Just the SSH URL (for git remote add)

bash /tmp/gitea-create-repo.sh dots-quantum | jq -r '.ssh_url'

===== Query Repos — Inspect Before Creating

.List all your repos on gitea
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[].name' | sort

.List with full names
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[].full_name' | sort

.Table format — name, SSH URL, visibility
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[] | [.name, .ssh_url, (if .private then "private" else "public" end)] | @tsv' | sort | column -t

.Query a specific repo — check if it exists
[source,bash]

bash /tmp/gitea-api.sh /repos/evanusmodestus/dots-quantum | jq '{name, ssh_url, clone_url, private, created_at}'

.Get just the SSH URL — for git remote configuration
[source,bash]

bash /tmp/gitea-api.sh /repos/evanusmodestus/dots-quantum | jq -r '.ssh_url'

.Count total repos
[source,bash]

bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq length

.Error handling — detect "already exists"
[source,bash]

bash /tmp/gitea-create-repo.sh dots-quantum | jq -r '.message // "success"'

Returns `The repository with the same name already exists.` or `success`.

===== Diff Local vs Gitea — Find Missing Repos

.Show which local repos are NOT on gitea yet
[source,bash]

gitea_repos=$(bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[].name' | sort) local_repos=$(find ~/atelier -maxdepth 4 -name '.git' -type d | \ while read repo; do basename "$(dirname "$repo")"; done | sort -u) comm -23 <(echo "$local_repos") <(echo "$gitea_repos")

`comm -23` shows lines only in the first file (local but not on gitea).

===== Bulk Create — All Repos from ~/atelier

.Dry run — show which repos would be created
[source,bash]

find ~/atelier -maxdepth 4 -name '.git' -type d | while read repo; do name=$(basename "$(dirname "$repo")") printf "%s\n" "$name" done | sort

.Create all missing repos on gitea
[source,bash]

Get list of existing repos on gitea (uses wrapper — avoids zsh ? glob)

existing=$(bash /tmp/gitea-api.sh '/user/repos?limit=50' | jq -r '.[].name')

Create only repos that don’t exist yet

find ~/atelier -maxdepth 4 -name '.git' -type d | while read repo; do name=$(basename "$(dirname "$repo")") if echo "$existing" | grep -qx "$name"; then printf "✅ %-35s exists\n" "$name" else result=$(bash /tmp/gitea-api.sh /user/repos POST "{\"name\":\"${name}\",\"private\":true}" 2>&1) if echo "$result" | jq -e '.id' >/dev/null 2>&1; then printf "🆕 %-35s created\n" "$name" else printf "❌ %-35s failed: %s\n" "$name" "$(echo "$result" | jq -r '.message // "unknown error"')" fi fi done

NOTE: `?` is a glob character in zsh. Always quote endpoints containing `?`: `'/user/repos?limit=50'` not `/user/repos?limit=50`.

===== jq Patterns for API Responses

[cols="1,2"]
|===
| Pattern | What It Does

| `jq .`
| Pretty-print full JSON response

| `jq '{name, ssh_url}'`
| Extract specific fields into a new object

| `jq -r '.ssh_url'`
| Raw output — no quotes, pipe-friendly

| `jq -r '.[] \| .name'`
| Iterate array, extract one field per line

| `jq -r '.[] \| [.name, .ssh_url] \| @tsv'`
| Array → tab-separated values for `column -t`

| `jq -e '.id'`
| Exit code 0 if field exists, 1 if null — use in conditionals

| `jq -r '.message // "unknown"'`
| Alternative operator — fallback if field is null
|===

===== Cleanup

[source,bash]

Unsource credentials

dsunsource

Rotate the token (exposed in terminal paste — generate new in browser)

10.50.1.70:3000/user/settings/applications

// Concern: Gitea TLS cert renewal — expired cert with wrong CN
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/gitea-cert-renewal.adoc

==== Gitea TLS Certificate Renewal

===== Problem

Gitea's TLS cert expired 2026-04-14 and has the wrong CN:

[source,console]

$ echo | openssl s_client -connect 10.50.1.70:3000 2>/dev/null | openssl x509 -noout -dates -subject notBefore=Jan 14 03:26:20 2026 GMT notAfter=Apr 14 03:26:19 2026 GMT subject=CN=guest.domusdigitalis.dev

Two issues:

1. **Expired** — over 2 months past `notAfter`
2. **Wrong CN** — `guest.domusdigitalis.dev` instead of `gitea-01.inside.domusdigitalis.dev`

Impact: `git push gitea` via LFS fails TLS validation. Currently bypassed with:

[source,bash]

git -c 'lfs.https://gitea-01.inside.domusdigitalis.dev:3000/.sslverify=false' push gitea main

===== Vault PKI — Load Credentials and Discover Roles

.Source Vault credentials
[source,bash]

dsource d000 dev/vault

.Verify Vault is reachable
[source,bash]

env | grep VAULT_ADDR | awk -F= '{print $1"=<loaded>"}' vault status | head -5

.List PKI mounts
[source,bash]

vault secrets list | grep pki

Output:

pki/ pki pki_149ca7fb n/a pki_int/ pki pki_9cdbba01 n/a

.List available roles
[source,bash]

vault list pki_int/roles

Output:

domus-byod domus-client domus-client-admins domus-client-users domus-server domus-windows-machine domus-windows-user research-client

.Inspect the server role — check allowed domains, max TTL
[source,bash]

vault read pki_int/roles/domus-server | grep -E 'allowed_domains|max_ttl|ttl|key_type'

===== Issue New Certificate

.Issue cert with correct CN and SAN
[source,bash]

vault write -format=json pki_int/issue/domus-server \ common_name="gitea-01.inside.domusdigitalis.dev" \ alt_names="gitea-01.inside.domusdigitalis.dev" \ ip_sans="10.50.1.70" \ ttl="8760h" | tee /tmp/gitea-cert.json | jq '.data | {serial_number, expiration}'

.Inspect the response structure
[source,bash]

jq 'keys' /tmp/gitea-cert.json jq '.data | keys' /tmp/gitea-cert.json

Response is nested: top-level has `data`, `lease_duration`, `request_id`, `warnings`.
Cert fields live under `.data`: `certificate`, `private_key`, `ca_chain`, `serial_number`, `expiration`.

.Extract cert, key, and CA chain to files
[source,bash]

jq -r '.data.certificate' /tmp/gitea-cert.json > /tmp/gitea-01.crt jq -r '.data.private_key' /tmp/gitea-cert.json > /tmp/gitea-01.key jq -r '.data.ca_chain[]' /tmp/gitea-cert.json > /tmp/gitea-01-ca.crt

.Verify the cert before deploying
[source,bash]

openssl x509 -in /tmp/gitea-01.crt -noout -dates -subject -ext subjectAltName

Expected: CN=gitea-01.inside.domusdigitalis.dev, valid for 1 year, SAN includes 10.50.1.70.

===== Deploy to Gitea (NAS Docker)

.Discovery — how the cert paths were found
[source,bash]

SSH to NAS (user is adminerosado, not evanusmodestus)

ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -l adminerosado nas-01

Find gitea container

sudo docker ps | grep -i gitea # → 69d7421584f3 gitea/gitea:latest Up 5 months 0.0.0.0:3000→3000/tcp, 0.0.0.0:2222→22/tcp

Find mount paths

sudo docker inspect gitea | jq '.[0].Mounts[] | {Source, Destination}' # → /volume1/docker/gitea/data → /data # → /volume1/docker/gitea/config → /etc/gitea

Find app.ini

find /volume1/docker/gitea -name 'app.ini' # → /volume1/docker/gitea/data/gitea/conf/app.ini

Find cert config

grep -i cert /volume1/docker/gitea/data/gitea/conf/app.ini # → CERT_FILE = /data/gitea/certs/fullchain.pem # → KEY_FILE = /data/gitea/certs/privkey.pem

Cert paths (container → host):

Container: /data/gitea/certs/fullchain.pem Host (NAS): /volume1/docker/gitea/data/gitea/certs/fullchain.pem

Container: /data/gitea/certs/privkey.pem Host (NAS): /volume1/docker/gitea/data/gitea/certs/privkey.pem

.Copy certs from workstation to NAS (scp fails — use cat | ssh tee)
[source,bash]

SCP fails on Synology (remote mkdir permission denied)

Pipe through SSH instead — /volume1/tmp must exist and be writable (chmod 777)

cat /tmp/gitea-01.crt | ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -l adminerosado nas-01 'tee /volume1/tmp/gitea-01.crt > /dev/null' cat /tmp/gitea-01.key | ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -l adminerosado nas-01 'tee /volume1/tmp/gitea-01.key > /dev/null' cat /tmp/gitea-01-ca.crt | ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -l adminerosado nas-01 'tee /volume1/tmp/gitea-01-ca.crt > /dev/null'

NOTE: Requires `sudo mkdir -p /volume1/tmp && sudo chmod 777 /volume1/tmp` on the NAS first.

.Deploy certs on NAS (run from NAS SSH session)
[source,bash]

Verify files arrived

ls -la /volume1/tmp/gitea-01*

Verify before — old cert

sudo openssl x509 -in /volume1/docker/gitea/data/gitea/certs/fullchain.pem -noout -dates -subject

Backup old certs

sudo cp /volume1/docker/gitea/data/gitea/certs/fullchain.pem \ /volume1/docker/gitea/data/gitea/certs/fullchain.pem.bak.$(date +%Y%m%d) sudo cp /volume1/docker/gitea/data/gitea/certs/privkey.pem \ /volume1/docker/gitea/data/gitea/certs/privkey.pem.bak.$(date +%Y%m%d)

Deploy new certs (fullchain = cert + CA chain)

sudo sh -c 'cat /volume1/tmp/gitea-01.crt /volume1/tmp/gitea-01-ca.crt > /volume1/docker/gitea/data/gitea/certs/fullchain.pem' sudo cp /volume1/tmp/gitea-01.key /volume1/docker/gitea/data/gitea/certs/privkey.pem sudo chmod 600 /volume1/docker/gitea/data/gitea/certs/privkey.pem

Restart gitea

sudo docker restart gitea

Verify after — new cert

sudo openssl x509 -in /volume1/docker/gitea/data/gitea/certs/fullchain.pem -noout -dates -subject

===== Verify After

.Check new cert from workstation
[source,bash]

echo | openssl s_client -connect 10.50.1.70:3000 -servername gitea-01.inside.domusdigitalis.dev 2>/dev/null | openssl x509 -noout -dates -subject

Expected: `CN=gitea-01.inside.domusdigitalis.dev`, `notAfter` ~1 year from now.

.Gitea with renewed TLS certificate — valid CN in browser
image::screenshots/infrastructure/gitea-cert-renewed-2026-06-21.png[Gitea login with valid TLS cert,width=100%]

.Test git push without TLS skip
[source,bash]

git -C ~/atelier/_bibliotheca/domus-captures push gitea main

.Test API
[source,bash]

bash /tmp/gitea-api.sh /user | jq '{login, email}'

===== Cleanup

.From NAS SSH session
[source,bash]

sudo rm /volume1/tmp/gitea-01* sudo rmdir /volume1/tmp

.From workstation
[source,bash]

rm /tmp/gitea-cert.json /tmp/gitea-01.crt /tmp/gitea-01.key /tmp/gitea-01-ca.crt

Unsource vault credentials

dsunsource

.Rotate exposed gitea PAT token

Browser: 10.50.1.70:3000/user/settings/applications → Delete old token → Generate new → update dsec/gopass

===== Prevention

This cert expired because there was no automated renewal for gitea.
Add to ACME cert automation project — gitea should renew via the same Vault+certbot pipeline as ISE.

===== Related

* ACME cert automation project — POC complete for ISE, extend to gitea
* gopass-gitea-remote-fix — the hostname and SSH key fixes that preceded this

// Concern: Vault SSH certificate renewal — expired cert blocking NAS access
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/vault-ssh-cert-renewal.adoc

==== Vault SSH Certificate Renewal

===== Problem

SSH to NAS and other infrastructure hosts failed with `Too many authentication failures`. Root cause: the Vault-signed SSH certificate expired 9 days ago. It had an 8-hour TTL and was never renewed.

.Diagnose — check cert validity
[source,bash]

ssh-keygen -L -f ~/.ssh/id_ed25519_vault-cert.pub | grep -E 'Valid|Principal|Type'

Output (expired):

Type: ssh-ed25519-cert-v01@openssh.com user certificate Valid: from 2026-06-11T21:47:54 to 2026-06-12T05:48:24 Principals:

Two problems: expired (June 12), and empty Principals (no usernames authorized).

===== Discovery — Vault SSH Mount and Role

.Source Vault credentials
[source,bash]

dsource d000 dev/vault

.Find the SSH secrets engine
[source,bash]

vault secrets list | grep ssh

Output: `ssh/` mount with `ssh_d682fece` accessor.

.List SSH roles
[source,bash]

vault list ssh/roles

Output: `domus-client` and `domus-host`.

.Inspect the client signing role
[source,bash]

vault read ssh/roles/domus-client | grep -E 'allowed_users|default_user|ttl|max_ttl|key_type'

Output:

allowed_users Administrator,domus\Administrator,admin,adminerosado,ansible,evanusmodestus,gabriel,root,u0_a361,u0_a385 default_user evanusmodestus key_type ca ttl 8h max_ttl 0s

===== Renew — Sign the Public Key

.Sign with Vault (overwrite expired cert)
[source,bash]

vault write -field=signed_key ssh/sign/domus-client \ public_key=@$HOME/.ssh/id_ed25519_vault.pub \ valid_principals="evanusmodestus,adminerosado,root" \ ttl="8h" >| ~/.ssh/id_ed25519_vault-cert.pub

NOTE: `>|` forces overwrite — zsh `noclobber` blocks `>` on existing files.

.Verify — must show future expiry and principals
[source,bash]

ssh-keygen -L -f ~/.ssh/id_ed25519_vault-cert.pub | grep -E 'Valid|Principal|Type'

Expected:

Type: ssh-ed25519-cert-v01@openssh.com user certificate Valid: from 2026-06-21T19:16:05 to 2026-06-22T03:16:35 Principals: evanusmodestus adminerosado root

.Test SSH
[source,bash]

ssh nas-01 ssh kvm-01

===== Daily Renewal — Shell Alias

The cert has an 8-hour TTL. Renew at the start of each session.

.Add to ~/.zshrc or ~/.zshenv
[source,bash]

Vault SSH cert renewal (8h TTL)

vault-ssh-renew() { dsource d000 dev/vault vault write -field=signed_key ssh/sign/domus-client \ public_key=@$HOME/.ssh/id_ed25519_vault.pub \ valid_principals="evanusmodestus,adminerosado,root" \ ttl="8h" >| ~/.ssh/id_ed25519_vault-cert.pub ssh-keygen -L -f ~/.ssh/id_ed25519_vault-cert.pub | grep -E 'Valid|Principal' dsunsource }

.Usage
[source,bash]

vault-ssh-renew

Sources Vault credentials, signs the key, verifies, unsources — one command.

===== Automated Renewal — systemd Timer

For hands-free renewal, a systemd user timer that runs every 7 hours (before the 8h expiry).

.Create the service unit
[source,bash]

tee ~/.config/systemd/user/vault-ssh-cert.service << 'EOF'

Description=Renew Vault SSH certificate

Type=oneshot Environment=VAULT_ADDR=https://vault-01.inside.domusdigitalis.dev:8200 ExecStart=/bin/bash -c '\ export VAULT_TOKEN=$(dsec show d000 dev/vault 2>/dev/null | grep VAULT_TOKEN | cut -d= -f2); \ vault write -field=signed_key ssh/sign/domus-client \ public_key=%h/.ssh/id_ed25519_vault.pub \ valid_principals="evanusmodestus,adminerosado,root" \ ttl="8h" >| %h/.ssh/id_ed25519_vault-cert.pub' EOF

.Create the timer unit
[source,bash]

tee ~/.config/systemd/user/vault-ssh-cert.timer << 'EOF'

Description=Renew Vault SSH certificate every 7 hours

OnBootSec=5min OnUnitActiveSec=7h Persistent=true

WantedBy=timers.target EOF

.Enable and start
[source,bash]

mkdir -p ~/.config/systemd/user systemctl --user daemon-reload systemctl --user enable --now vault-ssh-cert.timer

Verify

systemctl --user list-timers | grep vault systemctl --user status vault-ssh-cert.service

.Manual trigger
[source,bash]

systemctl --user start vault-ssh-cert.service ssh-keygen -L -f ~/.ssh/id_ed25519_vault-cert.pub | grep Valid

===== Why 8 Hours

Short-lived certificates are the security model — if a cert is compromised, the exposure window is 8 hours max. The tradeoff: you must renew daily. The shell alias makes this a single command; the systemd timer makes it automatic.

===== NAS-Specific Notes

* NAS SSH username is `adminerosado` (not `evanusmodestus`) — the cert must include this principal
* `MaxAuthTries` on the NAS is low — when the cert expires, SSH offers it first, it fails, and the remaining key offers exhaust the retry limit
* Password fallback: `ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no -l adminerosado nas-01`
* NAS password: `gopass show -c v3/domains/d000/storage/nas-01/admin`

// Concern: YubiKey 5C NFC setup — duplicate credentials from existing key
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/yubikey-5c-nfc-setup.adoc

==== YubiKey 5C NFC — New Key Setup

New YubiKey 5C NFC purchased. Goal: duplicate credentials from existing YubiKey so both keys work interchangeably as primary/backup pair.

===== Inventory — Current Keys

.List connected YubiKeys
[source,bash]

ykman list

Output (2026-06-21):

YubiKey 5C NFC (5.7.4) [OTP+FIDO+CCID] Serial: 35804317 YubiKey 5C NFC (5.7.1) [OTP+FIDO+CCID] Serial: 31311804

.Assign serials to variables — use throughout session
[source,bash]

NEW_YK=35804317 OLD_YK=31311804

.Check device info on each — --device selects by serial
[source,bash]

ykman --device $NEW_YK info ykman --device $OLD_YK info

.Compare both keys side by side — process substitution + diff
[source,bash]

diff <(ykman --device $OLD_YK info) <(ykman --device $NEW_YK info)

Only difference: serial and firmware (5.7.1 vs 5.7.4). All applications identical — both keys are ready for credential setup.

.Compare FIDO2 credentials — what the old key has that the new one needs
[source,bash]

diff <(ykman --device $OLD_YK fido credentials list 2>/dev/null) \ <(ykman --device $NEW_YK fido credentials list 2>/dev/null)

.Compare OATH accounts
[source,bash]

diff <(ykman --device $OLD_YK oath accounts list 2>/dev/null | sort) \ <(ykman --device $NEW_YK oath accounts list 2>/dev/null | sort)

.Compare PIV slots
[source,bash]

diff <(ykman --device $OLD_YK piv info 2>/dev/null) \ <(ykman --device $NEW_YK piv info 2>/dev/null)

===== Automated Discovery — No Hardcoded Serials

.Extract serials into an array from ykman output
[source,bash]

serials=($(ykman list | awk -F’Serial: ' '{print $2}'))

.Loop over all connected keys — info for each
[source,bash]

for serial in "${serials[@]}"; do printf "\n═══ YubiKey %s ═══\n" "$serial" ykman --device "$serial" info done

.Auto-diff two keys (no hardcoded serials)
[source,bash]

diff <(ykman --device "${serials[1]}" info) <(ykman --device "${serials[2]}" info)

.Full audit — info, FIDO2, OATH, PIV for every connected key
[source,bash]

for serial in "${serials[@]}"; do printf "\n═══ YubiKey %s ═══\n" "$serial" ykman --device "$serial" info printf "\n── FIDO2 Credentials ──\n" ykman --device "$serial" fido credentials list 2>/dev/null || echo "(none or PIN required)" printf "\n── OATH Accounts ──\n" ykman --device "$serial" oath accounts list 2>/dev/null || echo "(none or password required)" printf "\n── PIV Slots ──\n" ykman --device "$serial" piv info 2>/dev/null done

.Diff FIDO2 credentials between first and second key
[source,bash]

diff <(ykman --device "${serials[1]}" fido credentials list 2>/dev/null) \ <(ykman --device "${serials[2]}" fido credentials list 2>/dev/null)

.Diff OATH accounts
[source,bash]

diff <(ykman --device "${serials[1]}" oath accounts list 2>/dev/null | sort) \ <(ykman --device "${serials[2]}" oath accounts list 2>/dev/null | sort)

The pattern: `awk -F'Serial: ' '{print $2}'` splits on `Serial: ` and grabs the number. The array holds however many keys are plugged in — scales to 2, 3, or more without editing the script.

===== Pre-Setup — What Needs Duplication

[cols="1,2,2"]
|===
| Credential Type | Source (Existing Key) | Action for New Key

| FIDO2 SSH keys
| `id_ed25519_sk_rk_d000_nano`, `id_ed25519_sk_rk_d000`, `id_ed25519_sk_rk_d000_secondary`
| Generate new resident keys, add pubkeys to all hosts

| GPG (gopass)
| GPG subkeys on existing YubiKey
| Move copies of subkeys to new YubiKey (requires backup)

| FIDO2 WebAuthn
| GitHub, GitLab, Gitea, etc.
| Register new key as additional security key per site

| PIV
| If used for any certs
| Check: `ykman piv info`

| OATH/TOTP
| Any TOTP seeds
| Check: `ykman oath accounts list`
|===

===== Step 1 — Set PINs on New Key

.Set FIDO2 PIN
[source,bash]

ykman fido access change-pin

.Set PIV PIN (if using PIV)
[source,bash]

ykman piv access change-pin ykman piv access change-puk ykman piv access change-management-key --generate --protect

===== Step 2 — FIDO2 SSH Resident Keys

Generate new SSH keys on the new YubiKey. These are *new* keys — FIDO2 private keys cannot be exported from one YubiKey to another. Both keys' public keys must be added to `authorized_keys` on every host.

.Generate resident key on new YubiKey
[source,bash]

ssh-keygen -t ed25519-sk -O resident -O application=ssh:d000-5c-nfc \ -C "d000-5c-nfc-2026" -f ~/.ssh/id_ed25519_sk_rk_d000_5c_nfc

.Verify the key
[source,bash]

ssh-keygen -K # Download resident keys from YubiKey ssh-keygen -lf ~/.ssh/id_ed25519_sk_rk_d000_5c_nfc.pub

.Add to SSH config — update 20-d000-key-hierarchy.conf
[source,bash]

Verify before

grep 'IdentityFile.*sk_rk' ~/.ssh/config.d/20-d000-key-hierarchy.conf

Add the new key to the identity list (after existing YubiKey entries)

Then re-encrypt 80-d000-hosts.conf if key hierarchy changes

===== Step 3 — Deploy Public Key to All Hosts

.Copy pubkey to infrastructure hosts
[source,bash]

for host in kvm-0{1,2} nas-0{1,2} bind-0{1,2} vault-0{1,2,3}; do ssh-copy-id -i ~/.ssh/id_ed25519_sk_rk_d000_5c_nfc.pub "$host" && \ printf "✅ %s\n" "$host" || printf "❌ %s\n" "$host" done

NOTE: NAS requires `ssh-copy-id -o PreferredAuthentications=password -o PubkeyAuthentication=no -o User=adminerosado` since key auth uses the vault cert.

===== Step 4 — GPG Subkeys (for gopass)

FIDO2 keys are per-device. GPG keys *can* be duplicated — but only from a backup, not from another YubiKey. The private key on a YubiKey is non-exportable.

.Check if you have a GPG key backup
[source,bash]

Check gopass for GPG backup location

gopass ls --flat | grep -iE 'gpg|backup'

Check if master key is backed up

gpg --list-secret-keys --keyid-format long

.If backup exists — import to new YubiKey
[source,bash]

Import master key from backup

gpg --import /path/to/master-key-backup.gpg

Move subkeys to new YubiKey (DESTRUCTIVE — removes from local keyring)

gpg --edit-key <KEY_ID> # > keytocard (for each subkey: sign, encrypt, auth)

WARNING: `keytocard` is one-way — it moves the key OFF the local keyring onto the YubiKey. Always work from a backup copy, never the only copy.

===== Step 5 — WebAuthn Registration

Register the new YubiKey as an additional security key on each service. This is a browser operation — cannot be automated.

* GitHub: Settings → Security → Security keys → Add
* GitLab: Settings → Account → Two-factor → Register new device
* Gitea: `https://10.50.1.70:3000/user/settings/security` → Add Security Key

===== Step 6 — OATH/TOTP (if applicable)

.Check existing TOTP accounts
[source,bash]

ykman oath accounts list

TOTP seeds must be added individually to the new key — they cannot be cloned. Re-scan QR codes or use backup seeds stored in gopass.

===== Verification

.Test SSH with new YubiKey (remove old key, insert new)
[source,bash]

ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_sk_rk_d000_5c_nfc kvm-01

.Test gopass with new YubiKey
[source,bash]

gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq 'keys'

.List all resident keys on new YubiKey
[source,bash]

ykman fido credentials list

===== ykman Quick Reference

[cols="1,2"]
|===
| Command | What It Does

| `ykman list`
| List connected YubiKeys

| `ykman info`
| Device info — serial, firmware, interfaces

| `ykman fido info`
| FIDO2 status — PIN set, credentials count

| `ykman fido credentials list`
| List resident keys (FIDO2)

| `ykman fido access change-pin`
| Set or change FIDO2 PIN

| `ykman oath accounts list`
| List TOTP/HOTP accounts

| `ykman oath accounts code`
| Generate all TOTP codes

| `ykman oath accounts code <name>`
| Generate code for specific account

| `ykman piv info`
| PIV slot status

| `ykman config usb`
| Enable/disable USB interfaces (OTP, FIDO2, PIV, etc.)
|===

=== Education

==== Reading .xlsx Files with Python openpyxl

How to read Excel spreadsheets from the command line using Python's `openpyxl` library. Used to extract the CHLA annual review structure without opening Excel or LibreOffice.

===== Why openpyxl

`.xlsx` is a ZIP archive of XML files. `openpyxl` parses the XML and exposes cells, formulas, sheets, and formatting as Python objects. It's the standard library for Excel manipulation in Python — no Excel installation needed.

===== Setup — Temporary Virtual Environment

Arch Linux enforces PEP 668 — you can't `pip install` into the system Python. Use a temporary venv:

[source,bash]

python3 -m venv /tmp/xlsx-env /tmp/xlsx-env/bin/pip install openpyxl -q

The venv lives in `/tmp/` — it's disposable. Recreate it after reboot.

===== Set the Target File (Do This First)

Every command below uses `$XLSX`. Set it once:

[source,bash]

2026 annual review (current)

XLSX="data/d000/cursus/annual-review/chla/2026/my-annual-review-2026.xlsx"

2025 annual review (last year — read-only reference)

XLSX="data/d000/cursus/annual-review/chla/2025/my-annual-review.xlsx"

Any other xlsx

XLSX="/path/to/file.xlsx"

===== Dump All Cells to File

[source,bash]

/tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/xlsx-dump-$(date +%F).txt import openpyxl, sys

wb = openpyxl.load_workbook('${XLSX}') print("Sheets:", wb.sheetnames)

for sheet_name in wb.sheetnames: ws = wb[sheet_name] print(f"\n=== {sheet_name} ({ws.max_row} rows x {ws.max_column} cols) ===") for row in ws.iter_rows(max_row=ws.max_row, max_col=ws.max_column, values_only=False): for cell in row: if cell.value is not None: print(f" [{cell.coordinate}] {cell.value}") PYEOF

Then search the dump:

[source,bash]

grep -n 'C40\|C102\|C120\|C139\|C207\|J91\|J109\|J127' /tmp/xlsx-dump-$(date +%F).txt

===== Read a Single Cell (Full Content)

[source,bash]

CELL="C40" /tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/cell-${CELL}.txt import openpyxl

wb = openpyxl.load_workbook('${XLSX}') ws = wb['SelfEval'] cell = '${CELL}' print(f"[{cell}] ({len(str(ws[cell].value or ''))} chars)") print(ws[cell].value) PYEOF

===== Read a Single Cell from Any Sheet

[source,bash]

SHEET="LeaderReview" CELL="J105" /tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/cell-${SHEET}-${CELL}.txt import openpyxl

wb = openpyxl.load_workbook('${XLSX}') ws = wb['${SHEET}'] cell = '${CELL}' print(f"[{ws.title}!{cell}] ({len(str(ws[cell].value or ''))} chars)") print(ws[cell].value) PYEOF

===== Summary Table (All SelfEval Cells)

[source,bash]

/tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/xlsx-summary-$(date +%F).txt import openpyxl

wb = openpyxl.load_workbook('${XLSX}') ws = wb['SelfEval']

(cell, label, mode): 'value' prints raw, 'len' prints character count

cells = [ ('D32', 'Name', 'value'), ('D36', 'Title', 'value'), ('C40', 'Contributions', 'len'), ('G40', 'Meaningful Work', 'len'), ('I49', 'Individual Goal', 'len'), ('G50', 'Goal Achieved', 'value'), ('I57', 'Team Goal', 'len'), ('G58', 'Team Achieved', 'value'), ('C66', 'New Indiv Goal', 'len'), ('C71', 'New Team Goal', 'len'), ('J91', 'Job Skills', 'value'), ('C102', 'Skills Comments', 'len'), ('J109', 'Outcomes', 'value'), ('C120', 'Outcomes Cmt', 'len'), ('J127', 'Service', 'value'), ('C139', 'Service Comments','len'), ('J162', 'Collaboration', 'value'), ('J170', 'Empathy', 'value'), ('J178', 'Accountability', 'value'), ('J187', 'Growth', 'value'), ('J196', 'Service Care', 'value'), ('C207', 'Values Comments', 'len'), ('C232', 'Leader Support', 'len'), ('C239', 'Additional', 'len'), ]

for ref, label, mode in cells: raw = ws[ref].value if mode == 'len': print(f"{ref:5s} {label:20s} {len(str(raw or ''))} chars") else: print(f"{ref:5s} {label:20s} {raw or '(empty)'}") PYEOF

===== Edit a Cell

[source,bash]

CELL="C66" /tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/xlsx-edit-${CELL}.txt import openpyxl

wb = openpyxl.load_workbook('${XLSX}') ws = wb['SelfEval'] cell = '${CELL}'

print(f"BEFORE [{cell}] ({len(str(ws[cell].value or ''))} chars):") print(ws[cell].value)

ws[cell] = """Your updated text here."""

wb.save('${XLSX}')

wb2 = openpyxl.load_workbook('${XLSX}') val = wb2['SelfEval'][cell].value print(f"\nAFTER [{cell}] ({len(str(val or ''))} chars):") print(val) PYEOF

===== Edit Multiple Cells in One Pass

[source,bash]

/tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/xlsx-multi-edit.txt import openpyxl

wb = openpyxl.load_workbook('${XLSX}') ws = wb['SelfEval']

(cell, new_value) — add/remove as needed

edits = [ ('C66', """Updated individual goal text."""), ('C71', """Updated team goal text."""), ('C239', """Optional additional comments."""), ]

for cell, new_val in edits: old_len = len(str(ws[cell].value or '')) ws[cell] = new_val print(f"[{cell}] {old_len} → {len(new_val)} chars")

wb.save('${XLSX}')

Verify

wb2 = openpyxl.load_workbook('${XLSX}') ws2 = wb2['SelfEval'] print("\nVerification:") for cell, _ in edits: print(f" [{cell}] {len(str(ws2[cell].value or ''))} chars") PYEOF

===== Dump to JSON (for jq Processing)

[source,bash]

/tmp/xlsx-env/bin/python3 << PYEOF import openpyxl, json

wb = openpyxl.load_workbook('${XLSX}')

result = {} for sheet_name in wb.sheetnames: ws = wb[sheet_name] cells = {} for row in ws.iter_rows(max_row=ws.max_row, max_col=ws.max_column, values_only=False): for cell in row: if cell.value is not None: cells[cell.coordinate] = str(cell.value) result[sheet_name] = cells

with open('/tmp/annual-review.json', 'w') as f: json.dump(result, f, indent=2)

print(f"Written to /tmp/annual-review.json ({len(result)} sheets)") PYEOF

Search with jq

jq '.SelfEval | to_entries[] | select(.value | test("ISE|TEAP|CVE"; "i")) | {cell: .key, value: .value[:80]}' /tmp/annual-review.json

===== Read Leader Ratings (From LeaderReview Sheet)

[source,bash]

/tmp/xlsx-env/bin/python3 << PYEOF | tee /tmp/xlsx-leader-ratings.txt import openpyxl

wb = openpyxl.load_workbook('${XLSX}', data_only=True) ws = wb['LeaderReview']

(cell, category) — data drives the loop

ratings = [ ('J105', 'Job Skills'), ('J129', 'Outcomes'), ('J153', 'Service'), ('J194', 'Collaboration'), ('J202', 'Empathy'), ('J210', 'Accountability'), ('J219', 'Growth'), ('J228', 'Service Care'), ]

for cell, category in ratings: print(f"{cell:5s} {category:20s} {ws[cell].value or '(not yet rated)'}") PYEOF

===== Key Concepts

[cols="1,2"]
|===
| Concept | Code

| Load workbook
| `wb = openpyxl.load_workbook('file.xlsx')`

| List sheets
| `wb.sheetnames` → `['SelfEval', 'LeaderReview']`

| Select sheet
| `ws = wb['SelfEval']`

| Read single cell
| `ws['C32'].value` → `'Evan Rosado'`

| Read cell by row/col
| `ws.cell(row=32, column=3).value`

| Iterate all rows
| `ws.iter_rows(values_only=True)` → tuples of values

| Iterate with cell objects
| `ws.iter_rows(values_only=False)` → tuples of Cell objects

| Cell coordinate
| `cell.coordinate` → `'D32'`

| Cell row/column
| `cell.row` → `32`, `cell.column` → `4`

| Sheet dimensions
| `ws.max_row`, `ws.max_column`

| Merged cells
| `ws.merged_cells.ranges`
|===

===== PEP 668 — Why pip install Fails on Arch

Arch Linux marks the system Python as "externally managed" per PEP 668. Direct `pip install` is blocked to prevent breaking system packages.

[cols="1,2"]
|===
| Method | When to Use

| `python3 -m venv /tmp/env`
| Quick throwaway work (this session)

| `pipx install tool`
| CLI tools you want permanently

| `pacman -S python-openpyxl`
| If the package exists in Arch repos

| `--break-system-packages`
| Never. It's there for a reason.
|===

===== openpyxl vs Other Libraries

[cols="1,1,2"]
|===
| Library | Format | Use Case

| `openpyxl`
| `.xlsx` (Excel 2010+)
| Read/write modern Excel files

| `xlrd`
| `.xls` (legacy Excel)
| Read old Excel 97-2003 files

| `pandas`
| `.xlsx`, `.csv`, `.xls`
| DataFrames, analysis, pivot tables (heavyweight)

| `csv`
| `.csv`
| stdlib — no install needed

| `xlsxwriter`
| `.xlsx`
| Write-only, better formatting than openpyxl
|===

For reading HR forms like the annual review, `openpyxl` is the right tool — lightweight, no pandas overhead, direct cell access.

==== lsof — Session and Connection Management

`lsof` (List Open Files) is the universal diagnostic tool on Linux. Every socket, pipe, file handle, and network connection is a file descriptor — `lsof` shows them all. This is the hammer.

===== Kill Stuck SSH Sessions

[source,bash]

Find SSH connection to a specific host

lsof -i tcp@10.50.1.10:22

Terse mode — PID only (for command substitution)

lsof -ti tcp@10.50.1.10:22

Kill it

kill $(lsof -ti tcp@10.50.1.10:22)

Kill ALL SSH connections to a host (careful)

kill $(lsof -ti tcp@10.50.1.10)

===== Network Connections — Who's Talking to What

[source,bash]

All established connections

lsof -i -nP | grep ESTABLISHED

All connections to a specific remote IP

lsof -i @10.50.1.20 -nP

All connections on a specific port

lsof -i :443 -nP lsof -i :22 -nP lsof -i :1812 -nP # RADIUS

All connections by a specific user

lsof -u evanusmodestus -i -nP

TCP only, UDP only

lsof -i tcp -nP lsof -i udp -nP

Listening sockets (servers running on this machine)

lsof -i -nP | grep LISTEN

NOTE: `-n` = don't resolve hostnames (faster), `-P` = don't resolve port names (show numbers).

===== Process File Handles — What Does This Process Have Open?

[source,bash]

Everything a process has open (by PID)

lsof -p $(pgrep wpa_supplicant)

Everything a process has open (by name)

lsof -c wpa_supplicant

Just network connections for a process

lsof -c wpa_supplicant -i -nP

Just files (not sockets) for a process

lsof -c wpa_supplicant | grep -v 'IPv\|unix\|pipe'

What process is using a specific file?

lsof /etc/ssl/private/modestus-p16g-eaptls.key

What process is using a specific directory?

lsof +D /etc/wpa_supplicant/

===== Port Conflicts — Who's Holding That Port?

[source,bash]

What’s listening on port 8080?

lsof -i :8080 -nP

What’s holding port 53? (DNS)

sudo lsof -i :53 -nP

Kill whatever is holding a port

kill $(lsof -ti :8080)

Multiple ports at once

lsof -i :80,443,8080 -nP

===== File Lock Investigation — Why Can't I Write?

[source,bash]

Who has this file open?

lsof /var/log/syslog

Who has files open in this directory?

lsof +D /tmp/

Who’s writing to this file? (look for 'w' in FD column)

lsof /path/to/file | awk '$4 ~ /w/'

===== Deleted Files Still Consuming Disk

[source,bash]

Files deleted but still held open by a process (consuming disk)

lsof +L1

Same, but show sizes

lsof +L1 | awk 'NR==1 || $7 > 1048576' | sort -k7 -n

===== Unix Sockets — IPC Investigation

[source,bash]

All unix domain sockets

lsof -U

wpa_supplicant control socket

lsof -U -c wpa_supplicant

Docker sockets

lsof -U | grep docker

===== Combine with kill — Patterns

[source,bash]

Kill by connection target

kill $(lsof -ti tcp@10.50.1.10:22)

Kill all connections to a host

kill $(lsof -ti @10.50.1.40)

Kill process holding a port

kill $(lsof -ti :8080)

Kill process holding a file

kill $(lsof -t /tmp/lockfile)

Signal variants

kill -9 $(lsof -ti tcp@10.50.1.10:22) # SIGKILL (force) kill -HUP $(lsof -ti :443) # SIGHUP (reload) kill -TERM $(lsof -ti :8080) # SIGTERM (graceful, default)

===== Combine with watch — Live Monitoring

[source,bash]

Watch connections to ISE in real-time

watch -n 2 'lsof -i @10.50.1.20 -nP 2>/dev/null'

Watch wpa_supplicant connections

watch -n 1 'lsof -c wpa_supplicant -i -nP 2>/dev/null'

Count open files per process (top consumers)

watch -n 5 'lsof 2>/dev/null | awk "{print \$1}" | sort | uniq -c | sort -rn | head -10'

===== Real-World Scenarios

.Stuck SSH to a Cisco switch
[source,bash]

Scenario: SSH to 3560CX hangs, terminal unresponsive

lsof -i tcp@10.50.1.10:22 # COMMAND PID USER FD TYPE DEVICE NODE NAME # ssh 2052390 evanusmodestus 3u IPv4 TCP 10.50.1.203:35618→3560cx-01:ssh (ESTABLISHED)

kill 2052390 # Or: kill $(lsof -ti tcp@10.50.1.10:22)

.wpa_supplicant won't start — socket in use
[source,bash]

Scenario: "ctrl_iface exists" error

lsof +D /run/wpa_supplicant/ # Shows the stale process — kill it

kill $(lsof -t +D /run/wpa_supplicant/)

.Port 443 conflict — can't start a service
[source,bash]

Scenario: nginx/httpd/docker won’t bind

sudo lsof -i :443 -nP | grep LISTEN # Shows what’s holding it — kill or reconfigure

.Investigate what wpa_supplicant is doing during EAP
[source,bash]

While wpa_supplicant is authenticating, see all its file handles

lsof -c wpa_supplicant # You’ll see: the config file, the cert, the key, the control socket, # and the raw socket on enp134s0 for EAPOL frames

===== FD Column Reference

[cols="1,2"]
|===
| FD | Meaning

| `cwd` | Current working directory
| `rtd` | Root directory
| `txt` | Program text (binary)
| `mem` | Memory-mapped file
| `0r` | stdin (read)
| `1w` | stdout (write)
| `2w` | stderr (write)
| `3u` | File descriptor 3 (read/write)
| `4r` | File descriptor 4 (read only)
|===

`r` = read, `w` = write, `u` = read/write. The number is the file descriptor.

==== Command Forensics — Who Ran What, When

Techniques for auditing command execution on a Linux system — useful for security investigations, incident response, and feeding into Wazuh later.

===== Your Shell History (Immediate)

[source,bash]

Your zsh history with timestamps

fc -li 1 | tail -50

Search history for a specific command

fc -li 1 | grep -i 'tee\|wpa_supplicant\|sudo'

Full history file with timestamps (zsh EXTENDED_HISTORY format)

awk -F'[:;]' '/^:/ {ts=$2; gsub(/^ /,"",ts); cmd=substr($0,index($0,";")+1); print strftime("%Y-%m-%d %H:%M:%S",ts), cmd}' ~/.zsh_history | tail -50

Search across ALL users (requires root)

sudo find /home -name '.zsh_history' -o -name '.bash_history' | xargs grep -l 'pattern'

===== Process Accounting (`pacct` / `psacct`)

Kernel-level — logs every process execution regardless of shell history manipulation.

[source,bash]

Check if process accounting is active

command -v lastcomm && lastcomm --forwards | tail -20

If not installed

sudo pacman -S acct

sudo systemctl enable --now psacct.service

Who ran what

lastcomm --user root | head -20 lastcomm --command sudo | head -20

Summary by user

sa -u | head -30

===== Audit Framework (`auditd`)

The real forensic tool — kernel-level syscall auditing.

[source,bash]

Check if auditd is running

systemctl is-active auditd

Search audit logs for command execution (execve syscalls)

sudo ausearch -sc execve --start today | head -100

Filter by user

sudo ausearch -sc execve -ui $(id -u) --start today

Filter by command name

sudo ausearch -sc execve -x wpa_supplicant --start today

Human-readable report

sudo aureport -x --start today | tail -30

Add a watch rule for a specific binary

sudo auditctl -w /usr/bin/wpa_supplicant -p x -k wpa_auth_test

===== Journal (`systemd-journald`)

[source,bash]

All sudo commands today

journalctl _COMM=sudo --since today --no-pager

All commands by your user via sudo

journalctl _COMM=sudo --since today | grep "$(whoami)"

SSH logins

journalctl _COMM=sshd --since today --no-pager | grep -i 'accepted\|failed'

Service starts/stops (e.g. wpa_supplicant)

journalctl -u 'wpa_supplicant*' --since today --no-pager

===== File Access Forensics (`stat` + `find`)

[source,bash]

Files modified in the last hour

find /etc -mmin -60 -type f -ls 2>/dev/null

Files accessed (read) in the last hour (if atime not disabled)

find /etc -amin -60 -type f -ls 2>/dev/null

Compare ctime vs mtime — ctime tampered if mtime > ctime

find /etc/wpa_supplicant -type f -exec stat --format='%n mtime=%y ctime=%z' {} \;

Who owns recently changed files

find /etc -mmin -60 -type f -printf '%u %p\n' 2>/dev/null | sort

===== Live Process Monitoring

[source,bash]

What’s running right now (full command line)

ps auxww | grep -v grep | awk 'NR==1 || /wpa_supplicant|eapol/'

Real-time process creation (requires root)

sudo bpftrace -e 'tracepoint:sched:sched_process_exec { printf("%s %d %s\n", comm, pid, str(args→filename)); }'

Simpler: inotifywait on critical dirs

inotifywait -m -r /etc/wpa_supplicant /etc/ssl/private

===== Quick One-Liner: "What Did I Just Do?"

[source,bash]

Last 10 commands with timestamps

fc -li -10

Last 10 files modified anywhere under /etc

sudo find /etc -mmin -10 -type f -ls 2>/dev/null

Last 10 sudo commands from journal

journalctl _COMM=sudo -n 10 --no-pager -o short-iso

===== Wazuh Integration (Future)

These data sources map directly to Wazuh agent configuration:

[cols="1,1,1"]
|===
| Source | Wazuh Module | Config

| auditd
| `<localfile>` → `/var/log/audit/audit.log`
| Rule group `audit_command`

| journald
| `<journald>` module
| Filter by `_COMM`

| shell history
| `<localfile>` → `~/.zsh_history`
| Custom decoder needed

| file integrity
| `<syscheck>`
| Directories: `/etc/wpa_supplicant`, `/etc/ssl`
|===

When Wazuh indexer is deployed (blocked by k3s NAT — 92 days), these become searchable and alertable.

==== `stat()` Syscall — Forensics Foundation

Discovered through an `mv` error: `cannot stat` is not a generic error — it's the kernel telling you exactly which syscall failed and why.

===== The Error That Teaches

[source,bash]

mv ssh/.ssh/config.new ssh/.ssh/config # mv: cannot stat 'ssh/.ssh/config.new': No such file or directory

`mv` calls the `stat()` syscall BEFORE moving anything. It needs the file's metadata (inode, size, permissions, timestamps) to know what it's operating on. When `stat()` returns `ENOENT` (error: no entry), the kernel says "this path doesn't exist in the directory's inode table."

Every file operation starts with `stat()`:

[source,bash]

See what stat() returns — the same data structure every tool reads

stat ssh/.ssh/config

.Output breakdown
  File: ssh/.ssh/config
  Size: 11234         # bytes (st_size)
  Blocks: 24          # disk blocks allocated (st_blocks)
  IO Block: 4096      # filesystem block size (st_blksize)
Device: 0,55          # which filesystem (st_dev)
  Inode: 884303       # file's unique ID on this filesystem (st_ino)
  Links: 1            # hard link count (st_nlink)
Access: (0644/-rw-r--r--)  # permissions (st_mode)
  Uid: (1000/evanusmodestus)  # owner (st_uid)
  Gid: (1000/evanusmodestus)  # group (st_gid)
Access: 2026-06-21 10:25:00  # last read (st_atime)
Modify: 2026-06-20 20:42:00  # last content change (st_mtime)
Change: 2026-06-20 20:42:00  # last metadata change (st_ctime)
 Birth: 2026-06-20 20:34:00  # inode creation (btrfs preserves this)
===== Forensic Significance of Each Timestamp

[cols="1,2,2"]
|===
| Timestamp | What triggers it | Forensic value

| `atime` (Access)
| Reading the file (`cat`, `grep`, `bat`)
| Shows when the file was last opened — can prove access

| `mtime` (Modify)
| Writing content (`sed -i`, `echo >>`, editor save)
| Shows when content last changed — **can be faked** with `touch -t`

| `ctime` (Change)
| Any metadata change (rename, `chmod`, `chown`, content write)
| Shows when anything about the file changed — **cannot be faked** without raw disk access

| `Birth`
| Inode creation (`touch`, `cp`, first write)
| When the file was born — only available on modern filesystems (btrfs, ext4 with 256-byte inodes)
|===

.Key forensic insight
`mtime` can be spoofed: `touch -t 202501011200 file` sets any time you want. `ctime` **cannot** — it's updated by the kernel on every metadata operation. If `ctime` is newer than `mtime`, someone tampered with `mtime`.

===== The Inode — File Identity

The inode number is the file's true identity. Filenames are just directory entries pointing to inodes.

[source,bash]

Show inode numbers — prove two paths are the same or different file

stat -c '%i %n' ~/.ssh/config stat -c '%i %n' ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config

Same inode = same file (hard link or same path)

Different inode = different file objects on disk

A renamed file keeps its inode. A copied file gets a new one. This is how forensic tools track files across renames.

[source,bash]

Trace: did mv happen or was it a copy?

mv (same filesystem): inode stays the same, ctime updates

cp: new inode, new birth time, different st_ino

===== `strace` — Watch the Syscalls Live

You can watch `stat()` being called in real time:

[source,bash]

See every syscall mv makes

strace mv ssh/.ssh/config.new ssh/.ssh/config 2>&1 | grep stat

Output shows:

statx(AT_FDCWD, "ssh/.ssh/config.new", …​) = -1 ENOENT (No such file or directory)

`strace` intercepts every syscall between a program and the kernel. This is how you see what `mv`, `cp`, `rm`, `ls` actually DO — they're all just wrappers around `stat()`, `rename()`, `unlink()`, `readdir()`.

===== The C Connection

`stat()` maps directly to a C struct — this is what you'll implement in the syscall phase of your C curriculum:

[source,c]

#include <sys/stat.h>

struct stat sb; if (stat("ssh/.ssh/config", &sb) == -1) { perror("stat"); // prints: "stat: No such file or directory" return 1; }

printf("Inode: %lu\n", sb.st_ino); printf("Size: %ld bytes\n", sb.st_size); printf("Links: %lu\n", sb.st_nlink);

Every tool you use daily — `ls`, `find`, `mv`, `cp`, `rm` — calls this struct. When `mv` says `cannot stat`, it's this exact code path failing with `errno = ENOENT`.

===== Commands to Practice

[source,bash]

Compare timestamps of two files (forensic diff)

stat -c '%Y %n' file1 file2 | sort -n

Find files modified in the last hour

find . -mmin -60 -type f

Find files where ctime != mtime (possible tampering)

find . -type f -exec sh -c 'c=$(stat -c %Z "$1"); m=$(stat -c %Y "$1"); [ "$c" != "$m" ] && stat -c "%n mtime=%Y ctime=%Z" "$1"' _ {} \;

Watch syscalls on any command

strace -e trace=stat,statx,openat ls ~/.ssh/ 2>&1 | head -20

Get just the inode in a pipeline

stat -c '%i' ~/.ssh/config

==== DNS Inventory — `dig` Loop Automation

Built a single `for` loop that queries DNS for every infrastructure host. First real automation — replaces 19 manual `dig` commands with one pipeline.

===== The Loop

[source,bash]

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} vyos-vip bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana \ modestus-{p16g,aw,razer} thinkpad-x1 modestus-p50 gabriel-workstation; do printf "%-25s %s\n" "$name" \ "$(dig +short "${name}.inside.domusdigitalis.dev" @10.50.1.90)" done

.How it works
* `kvm-0{1,2}` — brace expansion generates `kvm-01 kvm-02` (NOT glob `[1,2]` which matches filenames)
* `dig +short` — returns just the IP, no headers
* `@10.50.1.90` — queries BIND primary directly (not system resolver)
* `printf "%-25s"` — left-aligned, 25-character-wide column for clean table output

===== Infrastructure Inventory (2026-06-21)

Queried against BIND primary (`@10.50.1.90`):

[cols="2,1,1"]
|===
| Host | IP | Status

| kvm-01 | 10.50.1.110 | Resolves
| kvm-02 | 10.50.1.111 | Resolves
| nas-01 | 10.50.1.70 | Resolves
| nas-02 | 10.50.1.71 | Resolves
| vyos-01 | 10.50.1.2 | Resolves
| vyos-02 | 10.50.1.3 | Resolves
| bind-01 | 10.50.1.90 | Resolves (primary)
| bind-02 | 10.50.1.91 | Resolves (secondary)
| ipa-01 | 10.50.1.100 | Resolves
| ipa-02 | 10.50.1.101 | Resolves
| keycloak-01 | 10.50.1.80 | Resolves
| keycloak-02 | 10.50.1.81 | Resolves
| ipsk-mgr-01 | 10.50.1.30 | Resolves
| ipsk-mgr-02 | 10.50.1.31 | Resolves
| vault-01 | 10.50.1.60 | Resolves
| vault-02 | 10.50.1.61 | Resolves
| vault-03 | 10.50.1.62 | Resolves
| certmgr-01 | 10.50.1.63 | Resolves
| home-dc01 | 10.50.1.50 | Resolves
| home-dc02 | 10.50.1.51 | Resolves
| 9800-wlc-01 | 10.50.1.40 | Resolves (alias: wlc-01)
| 9800-wlc-02 | 10.50.1.41 | Resolves (alias: wlc-02)
| k3s-master-01 | 10.50.1.120 | Resolves
| k3s-master-02 | 10.50.1.121 | Resolves
| k3s-master-03 | 10.50.1.122 | Resolves
| ise-01 | 10.50.1.20 | Resolves
| ise-02 | 10.50.1.21 | Resolves (VM shut off on kvm-02 — pending delete)
| gitea-01 | 10.50.1.70 | Resolves (same IP as nas-01)
| 3560cx-01 | 10.50.1.10 | Resolves (switch)
| 9300-01 | 10.50.1.11 | Resolves (switch)
| ipmi-01 | 10.50.1.200 | Resolves (BMC)
| ipmi-02 | 10.50.1.201 | Resolves (BMC)
| vyos-vip | 10.50.1.1 | Resolves (VRRP VIP)
| wazuh | 10.50.1.132 | Resolves
| wazuh-indexer | 10.50.1.130 | Resolves
| wazuh-dashboard | 10.50.1.133 | Resolves
| wazuh-workers | 10.50.1.134 | Resolves
| wazuh-api | 10.50.1.134 | Resolves (same IP as workers)
| prometheus | 10.50.1.131 | Resolves
| alertmanager | 10.50.1.131 | Resolves (same IP as prometheus)
| grafana | 10.50.1.131 | Resolves (same IP as prometheus)
| modestus-aw | 10.50.40.102 | Resolves (VLAN 40)
| modestus-razer | 10.50.10.130 | Resolves (VLAN 10)
| modestus-p16g | — | No A record (DHCP — currently 10.50.1.204)
| thinkpad-x1 | — | No A record (retired?)
| modestus-p50 | — | No A record (retired?)
| gabriel-workstation | — | No A record
|===

===== Observations

* **45 A records in zone** — confirmed via AXFR from bind-02
* **Shared IPs detected:** prometheus/alertmanager/grafana share 10.50.1.131; wazuh-workers/wazuh-api share 10.50.1.134; nas-01/gitea-01 share 10.50.1.70; 9800-wlc-01/wlc-01 share 10.50.1.40
* **modestus-p16g has no A record** — DHCP assigned (currently 10.50.1.204). Consider static A record or DHCP reservation.
* **modestus-aw is on VLAN 40** (10.50.40.x), **modestus-razer is on VLAN 10** (10.50.10.x) — different segments from infrastructure (10.50.1.x)
* **thinkpad-x1, modestus-p50, gabriel-workstation** — no DNS records. Retired or not yet registered.
* **ise-02** — DNS record exists but VM is shut off on kvm-02. Pending delete.

===== Verify against secondary DNS (`@10.50.1.91`)

[source,bash]

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} vyos-vip \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do printf "%-25s primary=%-15s secondary=%s\n" "$name" \ "$(dig +short "${name}.inside.domusdigitalis.dev" @10.50.1.90)" \ "$(dig +short "${name}.inside.domusdigitalis.dev" @10.50.1.91)" done

Both servers should return identical results. If they differ, zone transfer is stale.

===== Drift Detector

Silent when healthy, loud when broken — runbook check, not a script.

.Alert only on primary ≠ secondary
[source,bash]

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} vyos-vip \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do fqdn="${name}.inside.domusdigitalis.dev" p=$(dig +short "$fqdn" @10.50.1.90) s=$(dig +short "$fqdn" @10.50.1.91) if [[ "$p" != "$s" ]]; then printf "DRIFT %-20s primary=%-15s secondary=%s\n" "$name" "$p" "$s" fi done

No output = healthy. Any output = investigate immediately.

===== Generate `80-d000-hosts.conf` from DNS

This loop produces SSH config entries from the DNS inventory — the missing piece for the modular SSH config.
Run without redirect to preview, then append `> path` to write.

[source,bash]

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-0{1,2} vault-0{1,2,3} certmgr-01 \ home-dc0{1,2} 9800-wlc-0{1,2} wlc-0{1,2} k3s-master-0{1,2,3} \ ise-0{1,2} gitea-01 3560cx-01 9300-01 ipmi-0{1,2} vyos-vip \ wazuh wazuh-indexer wazuh-dashboard wazuh-workers wazuh-api \ prometheus alertmanager grafana modestus-{aw,razer}; do ip=$(dig +short "${name}.inside.domusdigitalis.dev" @10.50.1.90) [ -n "$ip" ] && printf "Host %s\n HostName %s\n\n" "$name" "$ip" done # Preview first — then write: # done >| ~/atelier/_projects/personal/dots-quantum/ssh/.ssh/config.d/80-d000-hosts.conf

===== KVM VM Inventory (2026-06-21)

After confirming SSH resolved all hosts, pulled VM state from both hypervisors.

.Remote inventory loop (run from workstation — no manual SSH needed)
[source,bash]

for host in kvm-0{1,2}; do printf "\n=== %s ===\n" "$host" ssh "$host" sudo virsh list --all done

.kvm-01 (10.50.1.110) — 10 VMs running
[listing]
....
 Id   Name            State
-------------------------------
 1    ipsk-mgr-01     running
 2    vyos-01         running
 3    bind-01         running
 4    vault-01        running
 5    home-dc01       running
 6    ipa-01          running
 7    9800-WLC-01     running
 8    k3s-master-01   running
 14   ise-01          running
 22   certmgr-01      running
....

.kvm-02 (10.50.1.111) — 5 VMs running, 1 stale
[listing]
....
 Id   Name          State
------------------------------
 8    bind-02       running
 9    vault-02      running
 10   vault-03      running
 12   vyos-02       running
 13   9800-WLC-02   running
 -    ise-02        shut off
....

WARNING: `ise-02` on kvm-02 is shut off and needs to be deleted — replaced by `ise-01` on kvm-01 during ISE rotation (2026-06-07).

.Delete stale ise-02
[source,bash]

Verify it’s shut off

ssh kvm-02 sudo virsh list --all | awk '/ise-02/'

Undefine (removes config) and delete storage

ssh kvm-02 sudo virsh undefine ise-02 --remove-all-storage

Verify removed

ssh kvm-02 sudo virsh list --all

===== Advanced: `paste` + Process Substitution + `awk`

Attempted a more advanced version using `paste` to merge two `dig` outputs side-by-side with `awk` formatting:

.Mistake made
[source,bash]

WRONG — fqdn assignment on same line as paste

for name in kvm-0{1,2}; do fqdn="${name}.inside.domusdigitalis.dev" paste <(dig +short "$fqdn" @10.50.1.90) <(dig +short "$fqdn" @10.50.1.91) \ | awk -v name="$name" '{printf "%-25s primary=%-15s secondary=%s\n", name, $1, $2}' done # Result: every host shows 10.50.1.30 — the PREVIOUS iteration’s fqdn leaks through

The shell interprets `VAR=value command` as "set `VAR` as an environment variable for `command` only — but it does NOT set `VAR` as a shell variable." The `<(dig +short "$fqdn")` process substitutions are expanded by the *shell* before `paste` runs. Since `$fqdn` is not a shell variable (only an env var scoped to `paste`), the shell expands `$fqdn` using whatever value it had from the *previous* iteration — or empty on the first pass.

.Fix — separate assignment from command
[source,bash]

RIGHT — semicolon or newline separates assignment from command

for name in kvm-0{1,2} nas-0{1,2} vyos-0{1,2} bind-0{1,2} ipa-0{1,2} \ keycloak-0{1,2} ipsk-mgr-01; do fqdn="${name}.inside.domusdigitalis.dev" paste <(dig +short "$fqdn" @10.50.1.90) <(dig +short "$fqdn" @10.50.1.91) \ | awk -v name="$name" '{printf "%-25s primary=%-15s secondary=%s\n", name, $1, $2}' done

.The distinction
[cols="1,2"]
|===
| Syntax | Meaning

| `VAR=value command`
| Set `VAR` as environment for `command` only — shell variable `$VAR` is NOT set for subshells like `$(dig ...)`

| `VAR=value; command`
| Set `VAR` as shell variable, THEN run `command` — `$VAR` is available to everything including subshells
|===

This is a subtle but critical shell parsing rule. The `VAR=value command` form is useful for things like `LANG=C sort file` — but when the command spawns subshells (process substitution, command substitution), those subshells don't see the variable the way you expect.

===== Concepts Learned

* **Brace expansion** — `{1,2}` generates strings, `[1,2]` matches filenames. Different mechanisms.
* **Command substitution in loops** — `$(dig +short ...)` embeds output as a variable inside `printf`
* **`printf` formatting** — `%-25s` left-aligns to 25 chars. Cleaner than `echo` for tabular output.
* **`+short` flag** — `dig` returns just the answer, no headers. Pipeline-friendly.
* **`@server`** — query a specific DNS server instead of the system resolver. Essential for verifying zone data.
* **Guard with `[ -n "$ip" ]`** — only print entries that resolved. Prevents empty `HostName` lines.

==== CLI Exploration — `sed`, `awk`, `find` + Command Substitution

Practical session navigating config files and project directories. Mistakes documented — each one teaches the right pattern.

===== `sed -n` vs `awk` for Line Ranges

Both extract lines by number. `sed` is simpler syntax, `awk` is more powerful when you need logic.

[source,bash]

sed — print lines 57 through 66

sed -n '57,66p' ~/.config/hypr/hyprland.conf

awk — same result, explicit comparison

awk 'NR>=57 && NR⇐66' ~/.config/hypr/hyprland.conf

.Mistake made
[source,bash]

WRONG — operands reversed

awk 'NR>=57&&NR66⇐' file # ← syntax error: "NR66" is not a variable

RIGHT — comparison operator goes between operands

awk 'NR>=57 && NR⇐66' file # ← NR (current line number) ⇐ 66

The `<=` operator needs a value on EACH side: `NR` (left) `<=` `66` (right). Writing `NR66<=` puts the number inside the variable name.

.When to use which
[cols="1,2"]
|===
| Tool | Use when

| `sed -n 'X,Yp'`
| Quick line extraction — fewer characters, no logic needed

| `awk 'NR>=X && NR<=Y'`
| Need to combine with field extraction, pattern matching, or formatting in the same command
|===

===== `find` + Command Substitution — Opening Results

The goal: find a directory by partial name, then operate on its contents.

[source,bash]

Step 1 — find the directory

find docs/modules/ROOT/partials -name 'CR-2026-06-19-p16g-hyp*'

Step 2 — list its contents (find returns a directory, not files)

ls $(find docs/modules/ROOT/partials -name 'CR-2026-06-19-p16g-hyp*')

Step 3 — tree view (better for directory results)

tree $(find docs/modules/ROOT/partials -name 'CR-2026-06-19-p16g-hyp*')

.Mistake made
[source,bash]

FAILS — bat doesn’t accept directories

bat $(find docs/modules/ROOT/partials -name 'CR-2026-06-19-p16g-hyp*') # [bat error]: '…​' is a directory.

`bat` (and `cat`) read FILES, not directories. When `find` returns a directory, you need to either:

[source,bash]

Option 1 — list then read specific files

ls $(find …​ -name 'CR-2026--hyp') bat $(find …​ -name 'CR-2026--hyp')/summary.adoc

Option 2 — find files inside the directory

find $(find …​ -name 'CR-2026--hyp') -name '*.adoc' -exec bat {}

Option 3 — find files directly (skip the directory step)

find docs/modules/ROOT/partials -path 'hyprlock-lid-wake' -name '*.adoc' -exec bat {}

Option 4 — use -type f to restrict find to files only

find docs/modules/ROOT/partials -path 'hyprlock' -type f

===== `find` Glob Narrowing — Getting Precise Results

Progressively narrowing the search:

[source,bash]

Too broad — all CRs from 2026

find docs/modules/ROOT/partials -name 'CR-2026-*' # Returns 8 results

Narrower — June only

find docs/modules/ROOT/partials -name 'CR-2026-06*' # Returns 2 results

Exact match

find docs/modules/ROOT/partials -name 'CR-2026-06-19-p16g-hyp*' # Returns 1 result (the directory)

.Mistake made
[source,bash]

WRONG — missing glob wildcard, matches nothing

find docs/modules/ROOT/partials -name 'CR-2026-19' # no month separator

RIGHT — date format is YYYY-MM-DD

find docs/modules/ROOT/partials -name 'CR-2026-06-19*' # full date prefix

The `-name` pattern must match the FULL filename. `CR-2026-19` skips the month (`06`), so it matches nothing. The file naming standard (STD-003) uses `YYYY-MM-DD` — always include all three date components.

===== Pattern: `find` → `tree` → `bat` Pipeline

The reliable three-step discovery pattern:

[source,bash]

1. Find it

find docs/ -name 'hyprlock-lid-wake'

2. See its structure

tree $(find docs/modules/ROOT/partials -path '*hyprlock-lid-wake')

3. Read a specific file

bat $(find docs/modules/ROOT/partials -path '*hyprlock-lid-wake')/evidence.adoc

This beats `cd` + `ls` + `cat` because it works from ANY directory — no navigation needed.

===== Real Code: `sed -i` for Config Surgery

The lid-wake fix is a `sed -i` operation. Here's how the same patterns apply to real code changes.

.Replace a specific string in a config file (verify-before/change/verify-after)
[source,bash]

Before — show what you’re about to change

grep -n 'Lid Switch' ~/.config/hypr/hyprland.conf

Change — in-place replacement with sed

sed -i '/switch:on:Lid Switch/s|exec, hyprctl keyword monitor "eDP-1, disable"|exec, loginctl lock-session \&\& brightnessctl -s set 0|' ~/.config/hypr/hyprland.conf

After — confirm the change took effect

grep -n 'Lid Switch' ~/.config/hypr/hyprland.conf

The `s|old|new|` uses `|` as the delimiter instead of `/` — because the old/new strings contain `/` and `"` which would need escaping otherwise. Pick a delimiter that doesn't appear in your strings.

.Change a value in antora.yml
[source,bash]

Before

awk '/current-day/' docs/antora.yml

Change — update day number

sed -i 's/current-day: 20/current-day: 21/' docs/antora.yml

After

awk '/current-day/' docs/antora.yml

.Comment out a block (add `#` to lines 57-66)
[source,bash]

sed -i '57,66s/^/# /' ~/.config/hypr/hyprland.conf

.Uncomment the same block (remove leading `# `)
[source,bash]

sed -i '57,66s/^# //' ~/.config/hypr/hyprland.conf

===== Real Code: `awk` for Extracting Structured Data

`awk` shines when you need to extract, filter, AND format in one pass.

.Extract all monitor names from hyprctl output
[source,bash]

hyprctl monitors | awk '/^Monitor/{print $2}'

.Extract attribute names and values from antora.yml
[source,bash]

awk -F': ' '/^ [a-z]/{print $1, "=", $2}' docs/antora.yml

.Count files per directory in a project (field extraction + associative array)
[source,bash]

find docs/modules/ROOT/partials -name '*.adoc' \ | awk -F/ '{dir=""; for(i=1;i<NF;i) dir=dir"/"$i; count[dir]} END {for(d in count) printf "%4d %s\n", count[d], d}' \ | sort -rn | head -10

.Extract all `include::` paths from a file (pattern match + field extraction)
[source,bash]

awk -F'[$\\[\\]]' '/^include::partial/{print $2}' \ docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-21.adoc

===== Real Code: `find -exec` for Batch Operations

`find` becomes powerful when you execute commands on the results.

.Verify every CR has a summary.adoc
[source,bash]

find docs/modules/ROOT/partials/case-studies/changes -maxdepth 1 -type d -name 'CR-*' \ -exec sh -c 'test -f "$1/summary.adoc" || echo "MISSING: $1/summary.adoc"' _ {} \;

.Batch check for hardcoded IPs across all partials
[source,bash]

find docs/modules/ROOT/partials -name '*.adoc' \ -exec grep -l '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' {}

.Count lines per partial in today's daily notes
[source,bash]

find docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-21 -name '*.adoc' \ -exec awk '{c++} END{printf "%4d %s\n", c, FILENAME}' {} \; \ | sort -rn

.Open all partials for a CR in nvim (command substitution + find)
[source,bash]

nvim $(find docs/modules/ROOT/partials -path 'hyprlock-lid-wake' -name '*.adoc')

This opens all 5 partials (summary, evidence, implementation, verification, rollback) in nvim's buffer list. Navigate with `:bn` and `:bp`.

// Concern: Graphviz diagram authoring — fundamentals and advanced features
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/graphviz-fundamentals.adoc

==== Graphviz — From First Render to Port-Targeted Wiring

Graphviz beats Visio on three axes: **structured node internals** (HTML labels with per-cell ports), **algorithmic layout** (you describe relationships, `dot` computes placement), and **version control** (plain text diffs, not binary blobs).

===== Workflow — Heredoc to SVG

Every diagram follows the same pattern: write source to `/tmp/`, render, inspect.

.Minimal — plain nodes and edges
[source,bash]

tee /tmp/minimal-topology.dot << 'EOF' digraph G { rankdir=TB node [shape=box, style="filled,rounded", fillcolor="#313244", fontcolor="#cdd6f4"] edge [color="#89b4fa"]

firewall [label="VyOS\n10.50.1.1"]
switch [label="3560-CX\n10.50.1.10"]
ise [label="ISE-01\n10.50.1.20"]
wlc [label="9800-wlc-01\n10.50.1.40"]
    firewall -> switch [label="Trunk"]
    ise -> switch [label="RADIUS"]
    wlc -> switch [label="CAPWAP"]
}
EOF
dot -Tsvg /tmp/minimal-topology.dot -o /tmp/minimal-topology.svg
dot -Tpng -Gdpi=150 /tmp/minimal-topology.dot -o /tmp/minimal-topology.png
Open the SVG in a browser (`xdg-open /tmp/minimal-topology.svg`) or the PNG in any viewer.
This gets you running in 30 seconds — everything below builds on this skeleton.

===== Feature 1 — HTML Labels (Structured Node Internals)

Plain `label="line\nline"` caps out fast.
HTML labels (`label=<...>`) give you `<TABLE>` with rows, columns, colors, and — critically — `PORT` attributes that edges can target.

.HTML label with ports
[source,bash]

tee /tmp/html-labels.dot << 'EOF' digraph G { bgcolor="#1e1e2e" node [shape=none, fontname="JetBrains Mono"] edge [fontname="JetBrains Mono", fontsize=9, fontcolor="#a6adc8"]

ise [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#3a1e1e" COLOR="#f38ba8">
        <TR><TD COLSPAN="2" BGCOLOR="#4a2a2a">
            <B><FONT COLOR="#f38ba8">ise-01 (Primary)</FONT></B>
        </TD></TR>
        <TR>
            <TD><FONT COLOR="#bac2de">IP</FONT></TD>
            <TD><FONT COLOR="#a6e3a1">10.50.1.20</FONT></TD>
        </TR>
        <TR>
            <TD PORT="radius"><FONT COLOR="#bac2de">RADIUS</FONT></TD>
            <TD><FONT COLOR="#89dceb">:1812/1813</FONT></TD>
        </TR>
        <TR>
            <TD PORT="ers"><FONT COLOR="#bac2de">ERS API</FONT></TD>
            <TD><FONT COLOR="#89dceb">:9060</FONT></TD>
        </TR>
        <TR>
            <TD PORT="dc"><FONT COLOR="#bac2de">DataConnect</FONT></TD>
            <TD><FONT COLOR="#89dceb">:2484</FONT></TD>
        </TR>
    </TABLE>
>]
switch [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e3e3a" COLOR="#94e2d5">
        <TR><TD COLSPAN="2" BGCOLOR="#2a4e4a">
            <B><FONT COLOR="#94e2d5">3560-CX</FONT></B>
        </TD></TR>
        <TR>
            <TD PORT="trunk"><FONT COLOR="#bac2de">Gi0/1 Trunk</FONT></TD>
            <TD PORT="dot1x"><FONT COLOR="#bac2de">Gi0/2-8 802.1X</FONT></TD>
        </TR>
    </TABLE>
>]
    // Port-targeted edge: RADIUS row → 802.1X ports
    ise:radius -> switch:dot1x [label="RADIUS Auth", color="#f38ba8", penwidth=2]
}
EOF
dot -Tsvg /tmp/html-labels.dot -o /tmp/html-labels.svg
The edge connects *from the RADIUS row* to *the 802.1X column* — not just "ISE to switch."
This is the precision Visio cannot match without manually dragging connection points.

===== Feature 2 — Subgraph Clusters

`subgraph cluster_<name>` draws a box around grouped nodes.
The `cluster_` prefix is mandatory — without it, no visible boundary.

.Clustered infrastructure layers
[source,bash]

tee /tmp/clusters.dot << 'EOF' digraph G { bgcolor="#1e1e2e" rankdir=TB node [shape=box, style="filled,rounded", fillcolor="#313244", fontcolor="#cdd6f4", fontname="JetBrains Mono", fontsize=10] edge [color="#89b4fa", fontcolor="#a6adc8", fontsize=8]

subgraph cluster_gateway {
    label="Gateway"
    style=filled
    fillcolor="#1a2634"
    color="#89b4fa"
    fontcolor="#89b4fa"
    vyos01 [label="vyos-01\nMASTER\n10.50.1.2", fillcolor="#1e3a5f"]
    vyos02 [label="vyos-02\nBACKUP\n10.50.1.3", fillcolor="#1e3a5f"]
    vyos01 -> vyos02 [label="VRRP", dir=both, style=dashed, color="#f38ba8"]
}
subgraph cluster_identity {
    label="Identity & Access"
    style=filled
    fillcolor="#2a1a1a"
    color="#f38ba8"
    fontcolor="#f38ba8"
    ise [label="ISE-01\n10.50.1.20", fillcolor="#3a1e1e"]
    dc01 [label="home-dc01\n10.50.1.50", fillcolor="#2a1e3a"]
    ise -> dc01 [label="LDAPS :636", color="#cba6f7"]
}
subgraph cluster_dns {
    label="DNS"
    style=dashed
    color="#89dceb"
    fontcolor="#89dceb"
    bind01 [label="bind-01\n10.50.1.90", fillcolor="#1e2e3a"]
    bind02 [label="bind-02\n10.50.1.91", fillcolor="#1e2e3a"]
    bind01 -> bind02 [label="AXFR", dir=both, color="#89dceb"]
}
    // Cross-cluster edges
    vyos01 -> ise [label="VLAN 100", color="#94e2d5"]
    ise -> bind01 [label="DNS :53", color="#89dceb"]
    dc01 -> bind01 [label="SRV Records", color="#cba6f7", style=dashed]
}
EOF
dot -Tsvg /tmp/clusters.dot -o /tmp/clusters.svg
===== Feature 3 — Layout Control

Four tools for controlling placement without fighting the algorithm:

.`rank=same` — force horizontal alignment
[source,dot]

{ rank=same; vyos01; vyos02; vrrp_vip }

.`splines=ortho` — right-angle edge routing (Manhattan geometry)
[source,dot]

digraph G { splines=ortho // No diagonal spaghetti nodesep=0.8 // Horizontal spacing ranksep=1.0 // Vertical spacing concentrate=true // Merge parallel edges }

.Invisible edges — structural constraints without visual artifacts
[source,dot]

nodeA → nodeB [style=invis] // Forces nodeA above nodeB

.`newrank=true` — enables rank constraints within subgraphs
[source,dot]

digraph G { newrank=true // Without this, rank=same inside clusters is ignored }

===== Feature 4 — Edge Semantics

Different edge styles encode different relationship types:

[source,dot]

a → b [penwidth=2.5, color="#a6e3a1"]

a → c [style=dashed, color="#f38ba8"]

hypervisor → vm [style=dotted, arrowhead=none, color="#585b70"]

primary → secondary [dir=both, label="sync"]

switch → router [style=bold, label="Trunk\nAll VLANs", penwidth=3]

===== Progression — Port Mapping (v1 through v8)

The goal: Cisco-style interface labels sitting on the cable at each end, not inside the node boxes.
This required six iterations to solve — each one taught a different Graphviz constraint.

====== Approach 1 — Port Cells in Switch Table

Ports as visible `<TD>` cells in the switch node, edges target `switch:g1` etc.
Works for showing which port, but the labels are *inside* the box — not on the cable.

.Port cells with targeted edges
[source,bash]

tee /tmp/port-mapping-v1.dot << 'EOF' digraph G { bgcolor="#1e1e2e" rankdir=TB splines=ortho node [shape=none, fontname="JetBrains Mono"] edge [fontname="JetBrains Mono", fontsize=8, fontcolor="#a6adc8"]

switch [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6"
           BGCOLOR="#1e3e3a" COLOR="#94e2d5">
        <TR><TD COLSPAN="4" BGCOLOR="#2a4e4a">
            <B><FONT COLOR="#94e2d5">3560-CX · 10.50.1.10</FONT></B>
        </TD></TR>
        <TR>
            <TD PORT="g1"><FONT COLOR="#bac2de">Gi0/1</FONT></TD>
            <TD PORT="g2"><FONT COLOR="#bac2de">Gi0/2</FONT></TD>
            <TD PORT="g3"><FONT COLOR="#bac2de">Gi0/3</FONT></TD>
            <TD PORT="g4"><FONT COLOR="#bac2de">Gi0/4</FONT></TD>
        </TR>
        <TR>
            <TD PORT="g5"><FONT COLOR="#bac2de">Gi0/5</FONT></TD>
            <TD PORT="g6"><FONT COLOR="#bac2de">Gi0/6</FONT></TD>
            <TD PORT="g7"><FONT COLOR="#bac2de">Gi0/7</FONT></TD>
            <TD PORT="g8"><FONT COLOR="#bac2de">Gi0/8</FONT></TD>
        </TR>
    </TABLE>
>]
vyos [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e3a5f" COLOR="#89b4fa">
        <TR><TD BGCOLOR="#264b7a"><B><FONT COLOR="#89b4fa">vyos-01</FONT></B></TD></TR>
        <TR><TD PORT="eth1"><FONT COLOR="#a6e3a1">eth1 · 10.50.1.2</FONT></TD></TR>
    </TABLE>
>]
ise [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#3a1e1e" COLOR="#f38ba8">
        <TR><TD BGCOLOR="#4a2a2a"><B><FONT COLOR="#f38ba8">ISE-01</FONT></B></TD></TR>
        <TR><TD PORT="eth0"><FONT COLOR="#a6e3a1">eth0 · 10.50.1.20</FONT></TD></TR>
    </TABLE>
>]
wlc [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2a3e" COLOR="#74c7ec">
        <TR><TD BGCOLOR="#2a3a4e"><B><FONT COLOR="#74c7ec">9800-WLC</FONT></B></TD></TR>
        <TR><TD PORT="mgmt"><FONT COLOR="#a6e3a1">mgmt · 10.50.1.40</FONT></TD></TR>
    </TABLE>
>]
razer [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#2a3e2a" COLOR="#a6e3a1">
        <TR><TD BGCOLOR="#3a4e3a"><B><FONT COLOR="#a6e3a1">modestus-razer</FONT></B></TD></TR>
        <TR><TD PORT="eth0"><FONT COLOR="#a6adc8">eth0 · 802.1X</FONT></TD></TR>
    </TABLE>
>]
    // Port-to-port — each edge lands on a specific switch interface
    vyos:eth1 -> switch:g1 [xlabel="Trunk", color="#89b4fa", penwidth=2]
    ise:eth0 -> switch:g2 [xlabel="RADIUS", color="#f38ba8", penwidth=2]
    wlc:mgmt -> switch:g3 [xlabel="CAPWAP", color="#74c7ec"]
    razer:eth0 -> switch:g5 [xlabel="EAP-TLS", color="#a6e3a1"]
}
EOF
dot -Tsvg /tmp/port-mapping-v1.dot -o /tmp/port-mapping-v1.svg
====== Approach 2 — headlabel/taillabel with ortho

`headlabel` places text at the arrow end, `taillabel` at the source.
This is the correct Graphviz mechanism — but **`splines=ortho` sabotages label placement**.
Labels drift to unpredictable positions because the orthogonal routing engine cannot compute label anchors on right-angle bends.

Key learning: `splines=ortho` and `label`/`headlabel`/`taillabel` are fundamentally incompatible.
Use `xlabel` with ortho, or switch to `splines=true` for head/tail labels.

====== Approach 3 — Intermediate Nodes as Port Labels

A different strategy: make the port labels into actual tiny nodes positioned on the cable by the layout engine.
Edges chain through them: `device → eth0-badge → Gi0/1-badge → switch`.

.Intermediate node approach
[source,bash]

tee /tmp/port-mapping-intermediate.dot << 'EOF' digraph G { bgcolor="#1e1e2e" rankdir=TB splines=ortho nodesep=0.8 ranksep=0.7 node [fontname="JetBrains Mono"] edge [fontname="JetBrains Mono", fontsize=8, fontcolor="#a6adc8"]

// ── Devices ─────────────────────────────────────────────────────────
node [shape=none]
vyos [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e3a5f" COLOR="#89b4fa">
        <TR><TD BGCOLOR="#264b7a"><B><FONT COLOR="#89b4fa">vyos-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.2</FONT></TD></TR>
    </TABLE>
>]
ise [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#3a1e1e" COLOR="#f38ba8">
        <TR><TD BGCOLOR="#4a2a2a"><B><FONT COLOR="#f38ba8">ISE-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.20</FONT></TD></TR>
    </TABLE>
>]
wlc [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2a3e" COLOR="#74c7ec">
        <TR><TD BGCOLOR="#2a3a4e"><B><FONT COLOR="#74c7ec">9800-WLC</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.40</FONT></TD></TR>
    </TABLE>
>]
razer [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#2a3e2a" COLOR="#a6e3a1">
        <TR><TD BGCOLOR="#3a4e3a"><B><FONT COLOR="#a6e3a1">modestus-razer</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6adc8">Arch Linux</FONT></TD></TR>
    </TABLE>
>]
switch [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6"
           BGCOLOR="#1e3e3a" COLOR="#94e2d5">
        <TR><TD BGCOLOR="#2a4e4a">
            <B><FONT COLOR="#94e2d5">3560-CX · 10.50.1.10</FONT></B>
        </TD></TR>
    </TABLE>
>]
// ── Port badge nodes — small opaque labels that sit on the cable ────
node [shape=box, style="filled,rounded", height=0.25, width=0.1,
      fontsize=8, margin="0.06,0.03"]
// Device-side badges (tails)
p_vyos   [label="eth1"   fillcolor="#264b7a" color="#264b7a" fontcolor="#89b4fa"]
p_ise    [label="eth0"   fillcolor="#4a2a2a" color="#4a2a2a" fontcolor="#f38ba8"]
p_wlc    [label="mgmt"   fillcolor="#2a3a4e" color="#2a3a4e" fontcolor="#74c7ec"]
p_razer  [label="eth0"   fillcolor="#2a3e2a" color="#2a3e2a" fontcolor="#a6e3a1"]
// Switch-side badges (heads)
g1 [label="Gi0/1" fillcolor="#2a4e4a" color="#2a4e4a" fontcolor="#94e2d5"]
g2 [label="Gi0/2" fillcolor="#2a4e4a" color="#2a4e4a" fontcolor="#94e2d5"]
g3 [label="Gi0/3" fillcolor="#2a4e4a" color="#2a4e4a" fontcolor="#94e2d5"]
g5 [label="Gi0/5" fillcolor="#2a4e4a" color="#2a4e4a" fontcolor="#94e2d5"]
// ── Wiring ──────────────────────────────────────────────────────────
// Device → device-port → protocol label → switch-port → switch
vyos  -> p_vyos  [arrowhead=none, color="#89b4fa", penwidth=2]
p_vyos -> g1     [arrowhead=none, color="#89b4fa", penwidth=2, xlabel="Trunk"]
g1 -> switch     [color="#89b4fa", penwidth=2]
ise   -> p_ise   [arrowhead=none, color="#f38ba8", penwidth=2]
p_ise -> g2      [arrowhead=none, color="#f38ba8", penwidth=2, xlabel="RADIUS"]
g2 -> switch     [color="#f38ba8", penwidth=2]
wlc   -> p_wlc   [arrowhead=none, color="#74c7ec", penwidth=1.5]
p_wlc -> g3      [arrowhead=none, color="#74c7ec", penwidth=1.5, xlabel="CAPWAP"]
g3 -> switch     [color="#74c7ec", penwidth=1.5]
razer -> p_razer  [arrowhead=none, color="#a6e3a1", penwidth=1.5]
p_razer -> g5     [arrowhead=none, color="#a6e3a1", penwidth=1.5, xlabel="EAP-TLS"]
g5 -> switch     [color="#a6e3a1", penwidth=1.5]
    // ── Layout control ──────────────────────────────────────────────────
    { rank=same; vyos; ise; wlc; razer }
    { rank=same; p_vyos; p_ise; p_wlc; p_razer }
    { rank=same; g1; g2; g3; g5 }
}
EOF
dot -Tsvg /tmp/port-mapping-intermediate.dot -o /tmp/port-mapping-intermediate.svg
Reliable positioning with ortho — the layout engine controls badge placement.
Works well when you need right-angle routing and precise label placement simultaneously.

====== Approach 4 — The Solution: splines=true + invisible port cells

The breakthrough: **drop `splines=ortho`**.
Curved splines support `headlabel`/`taillabel`/`label` placement accurately.
Invisible port cells (`POINT-SIZE="1"` spacer row) in the switch spread cables horizontally.

.Cable labels with curved splines and port-cell spread
[source,bash]

tee /tmp/port-mapping-solution.dot << 'EOF' digraph G { bgcolor="#1e1e2e" rankdir=TB splines=true nodesep=1.5 ranksep=1.8 node [shape=none, fontname="JetBrains Mono"] edge [fontname="JetBrains Mono", fontsize=8, fontcolor="#a6adc8"]

// ── Devices ─────────────────────────────────────────────────────────
vyos [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e3a5f" COLOR="#89b4fa">
        <TR><TD BGCOLOR="#264b7a"><B><FONT COLOR="#89b4fa">vyos-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.2</FONT></TD></TR>
    </TABLE>
>]
ise [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#3a1e1e" COLOR="#f38ba8">
        <TR><TD BGCOLOR="#4a2a2a"><B><FONT COLOR="#f38ba8">ISE-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.20</FONT></TD></TR>
    </TABLE>
>]
wlc [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2a3e" COLOR="#74c7ec">
        <TR><TD BGCOLOR="#2a3a4e"><B><FONT COLOR="#74c7ec">9800-WLC</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.40</FONT></TD></TR>
    </TABLE>
>]
razer [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#2a3e2a" COLOR="#a6e3a1">
        <TR><TD BGCOLOR="#3a4e3a"><B><FONT COLOR="#a6e3a1">modestus-razer</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6adc8">Arch Linux</FONT></TD></TR>
    </TABLE>
>]
// Switch with invisible port cells — cables land at different positions
switch [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6"
           BGCOLOR="#1e3e3a" COLOR="#94e2d5">
        <TR><TD COLSPAN="4" BGCOLOR="#2a4e4a">
            <B><FONT COLOR="#94e2d5">3560-CX · 10.50.1.10</FONT></B>
        </TD></TR>
        <TR>
            <TD PORT="g1"><FONT COLOR="#94e2d5" POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g2"><FONT COLOR="#94e2d5" POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g3"><FONT COLOR="#94e2d5" POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g5"><FONT COLOR="#94e2d5" POINT-SIZE="1"> </FONT></TD>
        </TR>
    </TABLE>
>]
// ── Cables — taillabel at device, headlabel at switch, label on cable
vyos -> switch:g1 [
    taillabel=<<FONT COLOR="#89b4fa" POINT-SIZE="8">eth1</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/1</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">Trunk · VLAN 100</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#89b4fa"
    penwidth=2
]
ise -> switch:g2 [
    taillabel=<<FONT COLOR="#f38ba8" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/2</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">RADIUS · 1812/1813</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#f38ba8"
    penwidth=2
]
wlc -> switch:g3 [
    taillabel=<<FONT COLOR="#74c7ec" POINT-SIZE="8">mgmt</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/3</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">CAPWAP</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#74c7ec"
    penwidth=1.5
]
razer -> switch:g5 [
    taillabel=<<FONT COLOR="#a6e3a1" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/5</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">EAP-TLS · 802.1X</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#a6e3a1"
    penwidth=1.5
]
    { rank=same; vyos; ise; wlc; razer }
}
EOF
dot -Tsvg /tmp/port-mapping-solution.dot -o /tmp/port-mapping-solution.svg
Three label types working together:

- `taillabel` — interface name at the device end (e.g., `eth1`)
- `headlabel` — switch port at the switch end (e.g., `Gi0/1`)
- `label` — protocol/subnet on the cable middle (e.g., `Trunk · VLAN 100`)

`labeldistance=2.0` and `labelangle=25` control positioning.
HTML `<FONT>` in labels — bare colored text, no boxes.

====== Capstone — Full Enterprise Topology (v8)

All techniques combined: HA pairs (VyOS VRRP, WLC SSO), KVM host relationships, 10-port switch, color-coded cable semantics.

.Full infrastructure wiring diagram
[source,bash]

tee /tmp/full-topology.dot << 'EOF' digraph G { bgcolor="#1e1e2e" rankdir=TB splines=true nodesep=1.0 ranksep=1.5 node [shape=none, fontname="JetBrains Mono"] edge [fontname="JetBrains Mono", fontsize=8, fontcolor="#a6adc8"]

// ════════════════════════════════════════════════════════════════════
// HYPERVISORS — no cluster, just rank control
// ════════════════════════════════════════════════════════════════════
kvm01 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#313244" COLOR="#cba6f7">
        <TR><TD BGCOLOR="#45475a"><B><FONT COLOR="#cba6f7">kvm-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.110</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">Supermicro E300-9D<BR/>Xeon D-2146NT · 128GB</FONT></TD></TR>
    </TABLE>
>]
kvm02 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#313244" COLOR="#cba6f7">
        <TR><TD BGCOLOR="#45475a"><B><FONT COLOR="#cba6f7">kvm-02</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.111</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">Supermicro E300</FONT></TD></TR>
    </TABLE>
>]
// ════════════════════════════════════════════════════════════════════
// NETWORK DEVICES
// ════════════════════════════════════════════════════════════════════
vyos01 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e3a5f" COLOR="#89b4fa">
        <TR><TD BGCOLOR="#264b7a"><B><FONT COLOR="#89b4fa">vyos-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.2</FONT></TD></TR>
        <TR><TD><FONT COLOR="#fab387" POINT-SIZE="7">MASTER · Pri 200</FONT></TD></TR>
    </TABLE>
>]
vyos02 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e3a5f" COLOR="#585b70">
        <TR><TD BGCOLOR="#264b7a"><B><FONT COLOR="#89b4fa">vyos-02</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.3</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">BACKUP · Pri 100</FONT></TD></TR>
    </TABLE>
>]
vrrp_vip [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4"
           BGCOLOR="#45475a" COLOR="#f9e2af">
        <TR><TD><B><FONT COLOR="#f9e2af">VRRP VIP</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.1</FONT></TD></TR>
    </TABLE>
>]
ise [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#3a1e1e" COLOR="#f38ba8">
        <TR><TD BGCOLOR="#4a2a2a"><B><FONT COLOR="#f38ba8">ISE-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.20</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">ISE 3.4 · All Personas</FONT></TD></TR>
    </TABLE>
>]
dc01 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#2a1e3a" COLOR="#cba6f7">
        <TR><TD BGCOLOR="#3a2a4a"><B><FONT COLOR="#cba6f7">home-dc01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.50</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">AD DS · AD CS</FONT></TD></TR>
    </TABLE>
>]
wlc01 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2a3e" COLOR="#74c7ec">
        <TR><TD BGCOLOR="#2a3a4e"><B><FONT COLOR="#74c7ec">9800-WLC-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.40</FONT></TD></TR>
        <TR><TD><FONT COLOR="#fab387" POINT-SIZE="7">Active</FONT></TD></TR>
    </TABLE>
>]
wlc02 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2a3e" COLOR="#585b70">
        <TR><TD BGCOLOR="#2a3a4e"><B><FONT COLOR="#74c7ec">9800-WLC-02</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.41</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">SSO Standby</FONT></TD></TR>
    </TABLE>
>]
bind01 [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2e3a" COLOR="#89dceb">
        <TR><TD BGCOLOR="#2a3e4a"><B><FONT COLOR="#89dceb">bind-01</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.1.90</FONT></TD></TR>
    </TABLE>
>]
vault [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#2e2a1e" COLOR="#fab387">
        <TR><TD BGCOLOR="#3e3a2a"><B><FONT COLOR="#fab387">Vault Cluster</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">.60 · .61 · .62</FONT></TD></TR>
        <TR><TD><FONT COLOR="#585b70" POINT-SIZE="7">PKI · SSH CA · ACME</FONT></TD></TR>
    </TABLE>
>]
razer [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#2a3e2a" COLOR="#a6e3a1">
        <TR><TD BGCOLOR="#3a4e3a"><B><FONT COLOR="#a6e3a1">modestus-razer</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6adc8">Arch Linux</FONT></TD></TR>
    </TABLE>
>]
ap [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="5"
           BGCOLOR="#1e2a3e" COLOR="#74c7ec">
        <TR><TD BGCOLOR="#2a3a4e"><B><FONT COLOR="#74c7ec">Aironet 4800</FONT></B></TD></TR>
        <TR><TD><FONT COLOR="#a6e3a1">10.50.10.101</FONT></TD></TR>
    </TABLE>
>]
// ════════════════════════════════════════════════════════════════════
// SWITCH
// ════════════════════════════════════════════════════════════════════
switch [label=<
    <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6"
           BGCOLOR="#1e3e3a" COLOR="#94e2d5">
        <TR><TD COLSPAN="10" BGCOLOR="#2a4e4a">
            <B><FONT COLOR="#94e2d5">3560-CX · 10.50.1.10</FONT></B>
        </TD></TR>
        <TR>
            <TD PORT="g1"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g2"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g3"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g4"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g5"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g6"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g7"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g8"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g9"><FONT POINT-SIZE="1"> </FONT></TD>
            <TD PORT="g10"><FONT POINT-SIZE="1"> </FONT></TD>
        </TR>
    </TABLE>
>]
// ════════════════════════════════════════════════════════════════════
// HA RELATIONSHIPS
// ════════════════════════════════════════════════════════════════════
vyos01 -> vyos02 [
    label=<<FONT COLOR="#f38ba8" POINT-SIZE="7">VRRP</FONT>>
    dir=both
    style=dashed
    color="#f38ba8"
    penwidth=1.5
    constraint=false
]
vrrp_vip -> vyos01 [
    label=<<FONT COLOR="#f9e2af" POINT-SIZE="7">Active</FONT>>
    color="#f9e2af"
    style=bold
    constraint=false
]
vrrp_vip -> vyos02 [
    style=dotted
    color="#585b70"
    constraint=false
]
wlc01 -> wlc02 [
    label=<<FONT COLOR="#74c7ec" POINT-SIZE="7">SSO</FONT>>
    dir=both
    style=dashed
    color="#74c7ec"
    penwidth=1.5
    constraint=false
]
// ════════════════════════════════════════════════════════════════════
// KVM hosts → VMs
// ════════════════════════════════════════════════════════════════════
kvm01 -> vyos01 [style=dotted, color="#585b70", arrowhead=none, label=<<FONT COLOR="#585b70" POINT-SIZE="6">hosts</FONT>>]
kvm01 -> ise [style=dotted, color="#585b70", arrowhead=none]
kvm01 -> dc01 [style=dotted, color="#585b70", arrowhead=none]
kvm01 -> wlc01 [style=dotted, color="#585b70", arrowhead=none]
kvm01 -> bind01 [style=dotted, color="#585b70", arrowhead=none]
kvm01 -> vault [style=dotted, color="#585b70", arrowhead=none]
kvm02 -> vyos02 [style=dotted, color="#585b70", arrowhead=none, label=<<FONT COLOR="#585b70" POINT-SIZE="6">hosts</FONT>>]
kvm02 -> wlc02 [style=dotted, color="#585b70", arrowhead=none]
// ════════════════════════════════════════════════════════════════════
// CABLES — device to switch
// ════════════════════════════════════════════════════════════════════
vyos01 -> switch:g1 [
    taillabel=<<FONT COLOR="#89b4fa" POINT-SIZE="8">eth1</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/1</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">Trunk · All VLANs</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#89b4fa"
    penwidth=2
]
vyos02 -> switch:g2 [
    taillabel=<<FONT COLOR="#89b4fa" POINT-SIZE="8">eth1</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/2</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">Trunk · Standby</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#585b70"
    penwidth=1.5
    style=dashed
]
ise -> switch:g3 [
    taillabel=<<FONT COLOR="#f38ba8" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/3</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">RADIUS · 1812/1813</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#f38ba8"
    penwidth=2
]
dc01 -> switch:g4 [
    taillabel=<<FONT COLOR="#cba6f7" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/4</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">LDAPS · Kerberos</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#cba6f7"
    penwidth=1.5
]
wlc01 -> switch:g5 [
    taillabel=<<FONT COLOR="#74c7ec" POINT-SIZE="8">mgmt</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/5</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">CAPWAP</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#74c7ec"
    penwidth=1.5
]
wlc02 -> switch:g6 [
    taillabel=<<FONT COLOR="#74c7ec" POINT-SIZE="8">mgmt</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/6</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#585b70"
    penwidth=1
    style=dashed
]
bind01 -> switch:g7 [
    taillabel=<<FONT COLOR="#89dceb" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/7</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">DNS · :53</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#89dceb"
    penwidth=1.5
]
vault -> switch:g8 [
    taillabel=<<FONT COLOR="#fab387" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/8</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">PKI · :8200</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#fab387"
    penwidth=1.5
]
razer -> switch:g9 [
    taillabel=<<FONT COLOR="#a6e3a1" POINT-SIZE="8">eth0</FONT>>
    headlabel=<<FONT COLOR="#94e2d5" POINT-SIZE="8">Gi0/9</FONT>>
    label=<<FONT COLOR="#585b70" POINT-SIZE="7">EAP-TLS · 802.1X</FONT>>
    labeldistance=2.0
    labelangle=25
    color="#a6e3a1"
    penwidth=1.5
]
// WLC → AP
wlc01 -> ap [
    label=<<FONT COLOR="#74c7ec" POINT-SIZE="7">CAPWAP</FONT>>
    color="#74c7ec"
    style=dashed
    constraint=false
]
    // ════════════════════════════════════════════════════════════════════
    // LAYOUT
    // ════════════════════════════════════════════════════════════════════
    { rank=same; kvm01; kvm02 }
    { rank=same; vyos01; vrrp_vip; vyos02; ise; dc01; wlc01; wlc02; bind01; vault; razer }
}
EOF
dot -Tsvg /tmp/full-topology.dot -o /tmp/full-topology.svg
Techniques combined in v8:

- `splines=true` — accurate label placement
- `switch:g1` through `switch:g9` — port-targeted cable spread
- `constraint=false` on HA/VRRP/SSO edges — prevents vertical distortion
- `rank=same` without `subgraph cluster_` — avoids the "already in a rankset" warning
- `style=dashed` for standby paths (vyos-02, wlc-02)
- `style=dotted` with `arrowhead=none` for KVM "hosts" relationships

===== Rendering Options

[source,bash]

SVG — best for web, scales infinitely, text is selectable

dot -Tsvg /tmp/diagram.dot -o /tmp/diagram.svg

PNG — high DPI for presentations

dot -Tpng -Gdpi=200 /tmp/diagram.dot -o /tmp/diagram.png

PDF — for print / inclusion in LaTeX

dot -Tpdf /tmp/diagram.dot -o /tmp/diagram.pdf

Different layout engines (dot is default)

neato -Tsvg /tmp/diagram.dot -o /tmp/neato.svg # Spring model (undirected) fdp -Tsvg /tmp/diagram.dot -o /tmp/fdp.svg # Force-directed circo -Tsvg /tmp/diagram.dot -o /tmp/circo.svg # Circular layout

`dot` is the right engine for network topologies (directed, hierarchical).
`neato` is useful for peer relationships where hierarchy is misleading.

===== Reference — Catppuccin Mocha Palette for Graphviz

[cols="2,1,2"]
|===
| Color | Hex | Use

| Base (background) | `#1e1e2e` | `bgcolor`
| Surface0 | `#313244` | Default node fill
| Surface1 | `#45475a` | Header rows
| Surface2 | `#585b70` | Borders, muted elements
| Text | `#cdd6f4` | Primary font color
| Subtext0 | `#a6adc8` | Edge labels, secondary text
| Red | `#f38ba8` | Identity / security
| Green | `#a6e3a1` | Endpoints / auth success
| Blue | `#89b4fa` | Network / WAN
| Peach | `#fab387` | PKI / secrets
| Mauve | `#cba6f7` | Directory / compute
| Teal | `#89dceb` | DNS
| Sapphire | `#74c7ec` | Wireless
| Yellow | `#f9e2af` | Warnings / VIPs
| Flamingo | `#f2cdcd` | Annotations
|===

// Concern: D2 diagram authoring — fundamentals and advanced features
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/d2-fundamentals.adoc

==== D2 — From First Render to Interactive SVGs

D2 beats Visio on **styling inheritance** (classes), **rich content** (markdown labels), **interactivity** (tooltips and links in SVG), and **diffability** (YAML-like syntax that reads like documentation).

===== Workflow — Heredoc to SVG

Same discipline as Graphviz: write to `/tmp/`, render, inspect.

.Minimal — containers and connections
[source,bash]

tee /tmp/minimal-d2.d2 << 'EOF' direction: down

gateway: VyOS Gateway { vyos-01: "vyos-01\n10.50.1.2\nMASTER" vyos-02: "vyos-02\n10.50.1.3\nBACKUP" vyos-01 <→ vyos-02: "VRRP" }

switch: "3560-CX\n10.50.1.10" ise: "ISE-01\n10.50.1.20"

gateway.vyos-01 → switch: "Trunk" ise → switch: "RADIUS" EOF d2 --dark-theme 200 /tmp/minimal-d2.d2 /tmp/minimal-d2.svg

Open with `xdg-open /tmp/minimal-d2.svg`.
Thirty seconds to a rendered topology — everything below adds precision.

===== Feature 1 — Classes (DRY Styling)

The single biggest time-saver D2 has over both Visio and Graphviz.
Define a style once, apply it by name.
Change the color in one place, every node of that class updates.

.Classes defined and applied
[source,bash]

tee /tmp/d2-classes.d2 << 'EOF' classes: { firewall: { shape: hexagon style: { fill: "#1e3a5f" stroke: "#89b4fa" stroke-width: 2 font-color: "#cdd6f4" } } identity: { shape: hexagon style: { fill: "#3a1e1e" stroke: "#f38ba8" stroke-width: 2 font-color: "#cdd6f4" } } server: { shape: rectangle style: { fill: "#313244" stroke: "#585b70" font-color: "#cdd6f4" border-radius: 8 } } # Edge classes — apply to connections radius-link: { style: { stroke: "#f38ba8" stroke-width: 2 } } dns-link: { style: { stroke: "#89dceb" } } }

direction: down

vyos-01: "vyos-01\nMASTER\n10.50.1.2" { class: firewall } vyos-02: "vyos-02\nBACKUP\n10.50.1.3" { class: firewall; style.opacity: 0.6 } ise-01: "ISE-01\n10.50.1.20" { class: identity } bind-01: "bind-01\n10.50.1.90" { class: server; style.stroke: "#89dceb" }

ise-01 → vyos-01: "RADIUS" { class: radius-link } ise-01 → bind-01: "DNS :53" { class: dns-link } EOF d2 --dark-theme 200 /tmp/d2-classes.d2 /tmp/d2-classes.svg

Note: `class: firewall` on `vyos-02` sets the base style, then `style.opacity: 0.6` overrides a single property — inheritance with targeted exceptions.

===== Feature 2 — Markdown Labels

Markdown labels render tables, bold, code, and lists inside nodes.
This is the feature that produces Visio-impossible detail density.

IMPORTANT: When the markdown content contains pipe characters (`|`) — which every markdown table does — use triple-pipe delimiters: `|||md ... |||`.
Single-pipe `|md ... |` causes D2 to misinterpret table `|` as the block terminator.

.Markdown label with embedded table
[source,bash]

tee /tmp/d2-markdown.d2 << 'EOF' classes: { detailed: { style: { fill: "#313244" stroke: "#f38ba8" stroke-width: 2 font-color: "#cdd6f4" border-radius: 8 } } }

ise-01: |||md # ise-01 (Primary) | API | Port | Status | |---|---|---| | ERS | :9060 | ✅ | | OpenAPI | :443 | ✅ | | DataConnect | :2484 | ✅ | | pxGrid | :8910 | ✅ | | RADIUS | :1812 | ✅ |

  **ISE 3.4** · All personas active
  Cert valid → `2027-06-07`
||| {
  class: detailed
}

vault: |||md # Vault Cluster (Raft) | Node | Address | Role | |---|---|---| | vault-01 | 10.50.1.60 | Leader | | vault-02 | 10.50.1.61 | Follower | | vault-03 | 10.50.1.62 | Follower |

  PKI CA · SSH CA · AppRole · **ACME**
||| {
  class: detailed
  style.stroke: "#fab387"
}

vault → ise-01: "ACME Cert Issuance" { style.stroke: "#fab387" style.stroke-dash: 3 } EOF d2 --dark-theme 200 /tmp/d2-markdown.d2 /tmp/d2-markdown.svg

===== Feature 3 — Tooltips and Links (Interactive SVG)

These two attributes make SVGs behave like lightweight dashboards.
`tooltip:` shows text on hover.
`link:` makes the node clickable.

.Interactive nodes
[source,bash]

tee /tmp/d2-interactive.d2 << 'EOF' direction: right

ise-01: "ISE-01\n10.50.1.20" { shape: hexagon style.fill: "#3a1e1e" style.stroke: "#f38ba8" style.font-color: "#cdd6f4" tooltip: "Primary ISE — Admin, MnT, PSN, pxGrid. Vault-issued cert valid to 2027-06-07." link: "https://10.50.1.20/admin" }

vault: "Vault\n10.50.1.60" { style.fill: "#2e2a1e" style.stroke: "#fab387" style.font-color: "#cdd6f4" tooltip: "3-node Raft cluster. PKI CA issues certs via ACME protocol." link: "https://10.50.1.60:8200" }

dc01: "home-dc01\n10.50.1.50" { style.fill: "#2a1e3a" style.stroke: "#cba6f7" style.font-color: "#cdd6f4" tooltip: "AD DS + AD CS (Root CA). Domain: inside.domusdigitalis.dev" }

vault → ise-01: "ACME" { style.stroke: "#fab387" } ise-01 → dc01: "LDAPS :636" { style.stroke: "#cba6f7" } EOF d2 --dark-theme 200 /tmp/d2-interactive.d2 /tmp/d2-interactive.svg

Open the SVG in a browser — hover over ISE to see the tooltip, click to open the admin panel.
Two layers of information density: visual at a glance, operational on interaction.

===== Feature 4 — Containers and Nesting

D2 containers are the equivalent of Graphviz `subgraph cluster_` but with cleaner syntax and unlimited depth.
Connections between nested nodes use dot-path addressing.

.Nested containers
[source,bash]

tee /tmp/d2-containers.d2 << 'EOF' direction: down

core: Core Services { style.fill: "#1a2a1a" style.stroke: "#a6e3a1"

identity: Identity {
  style.fill: "#2a1a1a"
  style.stroke: "#f38ba8"
  ise: "ISE-01" { style.fill: "#3a1e1e"; style.stroke: "#f38ba8" }
  dc01: "home-dc01" { style.fill: "#2a1e3a"; style.stroke: "#cba6f7" }
  ise -> dc01: "LDAPS"
}
dns: DNS {
  style.fill: "#1a2a3a"
  style.stroke: "#89dceb"
  bind-01: "bind-01" { style.fill: "#1e2e3a" }
  bind-02: "bind-02" { style.fill: "#1e2e3a" }
  bind-01 <-> bind-02: "AXFR"
}
  # Cross-container edge within core
  identity.ise -> dns.bind-01: "DNS :53"
  identity.dc01 -> dns.bind-01: "SRV Records"
}

physical: Physical Network { style.fill: "#1e2a2e" style.stroke: "#94e2d5"

  switch: "3560-CX" { style.fill: "#1e3e3a"; style.stroke: "#94e2d5" }
}

Cross-layer edge — dot-path addressing

core.identity.ise → physical.switch: "RADIUS :1812" { style.stroke: "#f38ba8" style.stroke-width: 2 } EOF d2 --dark-theme 200 /tmp/d2-containers.d2 /tmp/d2-containers.svg

===== Feature 5 — Grid Layout

`grid-columns` arranges children in a horizontal grid instead of the layout engine's default stacking.
Ideal for VLANs, SSIDs, endpoints — anything that should read as a row of peers.

.Grid layout for VLAN segmentation
[source,bash]

tee /tmp/d2-grid.d2 << 'EOF' direction: down

vlans: 802.1Q VLAN Segmentation { grid-columns: 4 style.fill: "#181825" style.stroke: "#585b70"

  v100: "VLAN 100\nManagement\n10.50.1.0/24" {
    style.fill: "#3a1e1e"
    style.stroke: "#f38ba8"
  }
  v10: "VLAN 10\nData (802.1X)\n10.50.10.0/24" {
    style.fill: "#1e3a1e"
    style.stroke: "#a6e3a1"
    style.stroke-width: 2
  }
  v40: "VLAN 40\nResearch\n10.50.40.0/24" {
    style.fill: "#1e2e3a"
    style.stroke: "#89dceb"
  }
  v50: "VLAN 50\nIoT (iPSK)\n10.50.50.0/24" {
    style.fill: "#2a1e3a"
    style.stroke: "#cba6f7"
  }
}

switch: "3560-CX" { style.fill: "#1e3e3a" style.stroke: "#94e2d5" }

switch → vlans.v100: "Native" switch → vlans.v10: "802.1X →" { style.stroke: "#a6e3a1"; style.stroke-width: 2 } switch → vlans.v40 switch → vlans.v50: "MAB →" { style.stroke: "#cba6f7" } EOF d2 --dark-theme 200 /tmp/d2-grid.d2 /tmp/d2-grid.svg

===== Feature 6 — Sketch Mode

`--sketch` renders with a hand-drawn aesthetic — useful for whiteboard-style presentations where pixel-perfect precision signals the wrong message.

[source,bash]

d2 --dark-theme 200 --sketch /tmp/d2-containers.d2 /tmp/d2-sketch.svg

Same source, completely different visual register. No redraw required.

===== Rendering Options

[source,bash]

Dark theme (200 = Catppuccin-adjacent dark)

d2 --dark-theme 200 input.d2 output.svg

Light theme (default)

d2 input.d2 output.svg

Sketch mode (hand-drawn aesthetic)

d2 --dark-theme 200 --sketch input.d2 output.svg

PNG with padding

d2 --dark-theme 200 --pad 50 input.d2 output.png

Watch mode — re-renders on save (split terminal with editor)

d2 --watch --dark-theme 200 input.d2 output.svg

Layout engines

d2 --layout dagre input.d2 output.svg # Default — good for hierarchies d2 --layout elk input.d2 output.svg # Better for complex graphs (if installed)

`--watch` is the killer workflow feature: edit in one terminal, the SVG updates live in the browser.

===== When Graphviz Wins Instead

D2 does not have port-based connections.
If you need an edge from "the RADIUS row of ISE" to "the 802.1X column of the switch," Graphviz's HTML labels with `PORT` are the only option.

D2's markdown labels are richer for content but cannot target sub-elements for edge attachment.

[cols="2,1,1"]
|===
| Capability | D2 | Graphviz

| DRY styling (classes) | ✅ Winner | ❌ Inline only
| Rich node content | ✅ Markdown | ✅ HTML tables
| Port-targeted edges | FAILne | ✅ Winner
| Interactive SVG | ✅ Tooltips + links | FAILne
| Layout control | Good (`grid-columns`) | ✅ `rank`, `splines`, invisible edges
| Dark theme | `--dark-theme 200` | Manual colors
| Hand-drawn mode | `--sketch` | FAILne
| Watch/live reload | `--watch` | FAILne
| Edge routing | Decent | ✅ `splines=ortho`
|===

Use D2 for **architecture and context** (rich nodes, interactive hover, presentation-ready).
Use Graphviz for **wiring precision** (which port connects to which port, orthogonal routing).

// Concern: Diagram workflow — verification, audit, and direct rendering from CLI
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/diagram-workflow-verification.adoc

==== Diagram Workflow — Verification & CLI Rendering

===== Direct Rendering (Skip the Heredoc)

The heredoc pattern (`tee /tmp/... << 'EOF'`) exists for the documentation reader who copies from the rendered Antora page.
As the author, render example files directly:

.Render a single example by name
[source,bash]

dot -Tsvg $(find -name '05-intermediate-nodes.dot') -o /tmp/intermediate.svg d2 --dark-theme 200 $(find -name '03-markdown-labels.d2') /tmp/markdown-labels.svg

.Render and open in one shot
[source,bash]

dot -Tsvg $(find -name '07-full-topology.dot') -o /tmp/full.svg && xdg-open /tmp/full.svg

===== Cross-Reference: Partial → Example File

Verify that an `include::example$` directive in a partial points at a real file on disk.

.Check a single include
[source,bash]

sed -n '/05-intermediate/p' $(find -name 'graphviz-fundamentals.adoc') && \ head -10 $(find -name '05-intermediate-*.dot')

Output shows the include line from the partial, then the first 10 lines of the target file — visual confirmation that the path resolves and the content is correct.

===== Audit All Includes in a Partial

Extract every `include::example$` path, strip the AsciiDoc syntax, and verify each file exists on disk.

.Audit all example includes in a partial
[source,bash]

grep -h 'include::example\$' $(find -name 'graphviz-fundamentals.adoc') | \ awk -F'[$\\[\\]]' '{print $2}' | while read f; do match=$(find docs/modules/ROOT/examples -name "$(basename "$f")") printf "%-55s %s\n" "$f" "${match:-MISSING}" done

Any line showing `MISSING` means a broken include — the partial references a file that does not exist.

.Audit ALL partials in a directory at once
[source,bash]

grep -rh 'include::example\$' docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-21/ | \ awk -F'[$\\[\\]]' '{print $2}' | sort -u | while read f; do match=$(find docs/modules/ROOT/examples -name "$(basename "$f")") printf "%-55s %s\n" "$f" "${match:-MISSING}" done

===== Batch Render All Examples

Test that every `.dot` and `.d2` file in the lab directory renders without error.

.Render all Graphviz examples
[source,bash]

for f in docs/modules/ROOT/examples/diagrams/lab/graphviz/*.dot; do out="/tmp/$(basename "${f%.dot}").svg" if dot -Tsvg "$f" -o "$out" 2>&1; then printf "✅ %s → %s\n" "$(basename "$f")" "$out" else printf "❌ %s FAILED\n" "$(basename "$f")" fi done

.Render all D2 examples
[source,bash]

for f in docs/modules/ROOT/examples/diagrams/lab/d2/*.d2; do out="/tmp/$(basename "${f%.d2}").svg" if d2 --dark-theme 200 "$f" "$out" 2>&1; then printf "✅ %s → %s\n" "$(basename "$f")" "$out" else printf "❌ %s FAILED\n" "$(basename "$f")" fi done

===== Jump to Line in Any File by Name

.Open a file at a specific line — find + command substitution + editor
[source,bash]

nvim +116 $(find -name 'gitea-cert-renewal.adoc')

.grep for the line number first, then jump
[source,bash]

grep -n 'openssl x509' $(find -name 'gitea-cert-renewal.adoc') nvim +116 $(find -name 'gitea-cert-renewal.adoc')

.One-liner — grep finds the line, awk extracts the number, nvim opens it
[source,bash]

nvim +$(grep -n 'openssl x509' $(find -name 'gitea-cert-renewal.adoc') | head -1 | awk -F: '{print $1}') $(find -name 'gitea-cert-renewal.adoc')

Works with any editor: `nvim`, `vim`, `code -g file:line`, `hx file:line`.

===== Pattern Anatomy

[cols="1,2"]
|===
| Pattern | What It Does

| `$(find -name '*.dot')`
| Command substitution — embeds `find` output as an argument to `dot`

| `sed -n '/pattern/p'`
| Print only lines matching pattern — targeted extraction without `grep`

| `awk -F'[$\\[\\]]' '{print $2}'`
| Split on `$`, `[`, `]` simultaneously — strips AsciiDoc `include::example$...[`

| `while read f; do ... done`
| Loop over piped input line-by-line — each line becomes `$f`

| `${match:-MISSING}`
| Parameter expansion — if `$match` is empty, substitute `MISSING`

| `"${f%.dot}"`
| Suffix removal — `07-full-topology.dot` becomes `07-full-topology`

| `printf "%-55s %s\n"`
| Left-aligned column — `%-55s` pads the first field to 55 characters
|===

// Concern: xargs pipeline patterns learned and used today
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/xargs-pipeline-patterns.adoc

==== xargs Pipeline Patterns — Commands Used Today

===== find | xargs tree — Multi-Match Directory Visualization

.Show the daily notes directory structure
[source,bash]

find -name '2026-06-21' | xargs tree

When `find` returns one match, equivalent to `tree path`. When `find` returns multiple matches, `tree` renders all of them — the pipeline form scales where the direct path doesn't.

.Same idea: find all graphviz example directories
[source,bash]

find -type d -name 'graphviz' | xargs tree

===== find | xargs wc — Bulk Line Counts

.Count lines in all today's partials
[source,bash]

find docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-21/ -name '*.adoc' | xargs wc -l

.Count lines in all diagram examples
[source,bash]

find docs/modules/ROOT/examples/diagrams/lab/ -type f | xargs wc -l

===== find | xargs grep — Search Across Matched Files

.Find all includes referencing a specific example
[source,bash]

find -name '.adoc' -path '/partials/*' | xargs grep -l '07-full-topology'

`-l` returns filenames only — which partials reference the full topology diagram.

.Find all files using a specific IP
[source,bash]

find -name '.dot' -o -name '.d2' | xargs grep -l '10.50.1.110'

===== find | xargs dot — Batch Render Diagrams

.Render every .dot file to SVG
[source,bash]

find docs/modules/ROOT/examples/diagrams/lab/graphviz/ -name '*.dot' | \ xargs -I{} sh -c 'dot -Tsvg "$1" -o "/tmp/$(basename "${1%.dot}.svg")"' _ {}

`-I{}` sets the placeholder. `sh -c` wraps the command so `basename` and parameter expansion work inside xargs.

.Same with d2
[source,bash]

find docs/modules/ROOT/examples/diagrams/lab/d2/ -name '*.d2' | \ xargs -I{} sh -c 'd2 --dark-theme 200 "$1" "/tmp/$(basename "${1%.d2}.svg")"' _ {}

===== find | xargs diff — Compare Files

.Diff two versions of a diagram found by name
[source,bash]

diff <(find -name '06-cable-labels*') <(find -name '07-full-topology*')

Process substitution here, not xargs — but the instinct to compose `find` output as arguments applies in both.

===== Parallel Execution

.Render all diagrams in parallel (4 workers)
[source,bash]

find docs/modules/ROOT/examples/diagrams/lab/graphviz/ -name '*.dot' -print0 | \ xargs -0 -P4 -I{} sh -c 'dot -Tsvg "$1" -o "/tmp/$(basename "${1%.dot}.svg")"' _ {}

* `-print0` — null-delimited output (safe for filenames with spaces)
* `-0` — xargs reads null-delimited input
* `-P4` — 4 parallel workers

===== Pattern Reference

[cols="1,2"]
|===
| Pattern | What It Does

| `find ... \| xargs cmd`
| Feed find results as arguments to `cmd`

| `xargs -I{}`
| Replace `{}` with each input line — one invocation per line

| `xargs -n2`
| Batch 2 arguments per invocation

| `xargs -P4`
| Run 4 invocations in parallel

| `xargs -0` + `find -print0`
| Null-delimited — safe for filenames with spaces/special chars

| `xargs sh -c '...' _ {}`
| Shell wrapper — enables parameter expansion inside xargs

| `xargs -p`
| Prompt before each execution — dry-run mode

| `xargs -t`
| Print each command before executing — trace mode
|===

===== When NOT to Use xargs

[source,bash]

Direct path is simpler when you know it

tree docs/modules/ROOT/partials/worklog/daily-notes/2026-06/2026-06-21

Command substitution when you need the result as an argument

dot -Tsvg $(find -name '07-full-topology.dot') -o /tmp/full.svg

for loop when you need per-item logic (conditionals, printf)

for f in *.dot; do dot -Tsvg "$f" -o "/tmp/${f%.dot}.svg" && echo "✅ $f" done

`xargs` wins when: many files, parallel execution needed, or the command doesn't accept piped stdin. `$(find)` wins when: single file, inline argument. `for` wins when: per-item conditionals or formatted output.

// Concern: gopass pipeline patterns — ls, grep, xargs, awk for secret store operations
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/gopass-pipeline-patterns.adoc

==== gopass Pipeline Patterns

All commands inspect *paths and keys only* — never expose secret values in terminal output or pipes.

===== Search and Filter

.Flat list + grep — find entries by pattern
[source,bash]

gopass ls --flat | grep -i ise

.Multiple patterns — find all network device credentials
[source,bash]

gopass ls --flat | grep -iE 'ise|vyos|wlc|switch|bind'

.Count entries per directory — inventory the store
[source,bash]

gopass ls --flat | awk -F/ '{print $1"/"$2"/"$3}' | sort | uniq -c | sort -rn

Splits on `/`, keeps the first 3 path segments, counts entries per subtree. Shows where credentials concentrate.

.Find entries with a specific YAML key
[source,bash]

gopass ls --flat | while read entry; do gopass show "$entry" 2>/dev/null | grep -q '^username:' && echo "$entry" done

Slow (one gopass call per entry) but finds every entry containing a `username:` field.

===== Copy to Clipboard — Targeted Access

.Copy password for a matched entry
[source,bash]

gopass -c "$(gopass ls --flat | grep -i ise-01)"

Command substitution resolves the path, `-c` copies the password. Works when `grep` returns exactly one match.

.Interactive selection with fzf
[source,bash]

gopass -c "$(gopass ls --flat | fzf)"

Fuzzy search the entire store. Select with arrow keys, password copies on enter.

.Copy a specific field
[source,bash]

gopass show -c "$(gopass ls --flat | fzf)" '.account.username'

===== Bulk Operations with xargs

.List all entries under a path — keys only, no values
[source,bash]

gopass ls --flat | grep '^v3/domains/d000/identity/' | xargs -I{} basename {}

Extracts just the entry names from a subtree.

.Audit: find entries missing a specific field
[source,bash]

gopass ls --flat | grep '^v3/domains/' | while read entry; do gopass show "$entry" 2>/dev/null | grep -q '^url:' || printf "MISSING url: %s\n" "$entry" done

.Export paths (not values) for documentation
[source,bash]

gopass ls --flat | grep '^v3/domains/d000/identity/' | \ awk -F/ '{printf "| %s | %s |\n", $NF, $0}'

Generates an AsciiDoc table of entry names and their gopass paths — safe for documentation because no values are exposed.

===== Environment Variables — Never Touch Disk

.Load credentials into env for a script
[source,bash]

export ISE_USER="$(gopass show v3/domains/d000/identity/ise/ise-01 '.account.username')" export ISE_PASS="$(gopass show -o v3/domains/d000/identity/ise/ise-01)"

Password lives in process memory only — never written to a file, never in shell history (if the export is in a script, not typed raw).

.Use in a curl call — inline substitution
[source,bash]

curl -sk -u "$(gopass show v3/domains/d000/identity/ise/ise-01 '.account.username'):$(gopass show -o v3/domains/d000/identity/ise/ise-01)" \ ise-01.inside.domusdigitalis.dev:9060/ers/config/internaluser

===== yq Integration — Query Structured Entries

gopass entries use YAML after the password line. Pipe to `yq` for structured access.

.List all keys in an entry (never exposes values)
[source,bash]

gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq 'keys'

The password is the first line (before `---`). `sed '1,/^---$/d'` strips the password and separator, leaving clean YAML for `yq`. Without this, `yq` errors on the non-YAML password line.

.Extract a specific field
[source,bash]

gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq -r '.hostname' gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq -r '.version' gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq -r '.ports'

.Extract nested credentials to clipboard (Wayland)
[source,bash]

gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq -r '.credentials.username' | wl-copy

.Build a summary table from structured entries
[source,bash]

for entry in $(gopass ls --flat | grep 'identity/ise/' | grep -v '.meta'); do name=$(basename "$entry") ver=$(gopass show "$entry" | sed '1,/^---$/d' | yq -r '.version // "—"') ip=$(gopass show "$entry" | sed '1,/^---$/d' | yq -r '.ip // "—"') printf "%-15s %-12s %s\n" "$name" "$ver" "$ip" done

.Compare YAML structure between two entries
[source,bash]

diff <(gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq 'keys') \ <(gopass show v3/domains/d001/identity/ise/ppan | sed '1,/^---$/d' | yq 'keys')

Shows which fields exist in one entry but not the other — structural audit without exposing values.

.Extract ports as a flat list
[source,bash]

gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq -r '.ports | to_entries[] | .key + ": " + (.value | tostring)'

===== wl-copy — Wayland Clipboard

.Copy a yq-extracted field to clipboard
[source,bash]

gopass show v3/domains/d000/identity/ise/ise-02 | sed '1,/^---$/d' | yq -r '.credentials.username' | wl-copy

.Copy password directly (first line only)
[source,bash]

gopass show -o v3/domains/d000/identity/ise/ise-02 | wl-copy

.Paste from clipboard into a variable (never touches disk)
[source,bash]

ISE_PASS=$(wl-paste)

===== Diff and Audit

.Compare two entries — keys only, not values
[source,bash]

diff <(gopass show v3/domains/d000/identity/ise/ise-01 | grep -v '^[A-Za-z0-9+/=]' | sort) \ <(gopass show v3/domains/d000/identity/ise/ise-02 | grep -v '^[A-Za-z0-9+/=]' | sort)

Process substitution feeds both entries into `diff`. The `grep -v` strips the password line (first line, typically base64-like). Shows which YAML keys differ between entries.

.Find stale entries — older than N days (git-backed)
[source,bash]

gopass ls --flat | while read entry; do age=$(gopass history "$entry" 2>/dev/null | head -1 | awk '{print $1}') [ -n "$age" ] && printf "%-50s %s\n" "$entry" "$age" done | sort -k2

===== Pattern Reference

[cols="1,2"]
|===
| Pattern | What It Does

| `gopass ls --flat`
| All entries as flat paths — pipe-friendly, no tree formatting

| `gopass ls --flat \| grep -i`
| Case-insensitive search across all entry paths

| `gopass show -o`
| Password only (first line) — for `$(...)` substitution

| `gopass show entry '.key'`
| Dot-path YAML access — single quotes mandatory

| `gopass -c`
| Copy password to clipboard (45s auto-clear)

| `gopass show -c entry '.key'`
| Copy a specific YAML field to clipboard

| `gopass ls --flat \| fzf`
| Interactive fuzzy search — pipe result to `gopass -c`

| `gopass ls --flat \| awk -F/`
| Split paths into segments for counting, grouping, or table generation
|===

===== Network Connectivity Test Commands

Reference commands for validating network connectivity, DNS resolution, TLS certificates, and HTTP reachability. Useful for verifying guest SSID end-to-end and general troubleshooting.

==== DNS Resolution

.Forward lookup
[source,bash]

dig +short cloudflare.com dig +short guest.domusdigitalis.dev @10.50.1.90

.Reverse lookup
[source,bash]

dig -x 10.50.1.20 +short

.Trace resolution path
[source,bash]

dig +trace cloudflare.com

.Query specific record types
[source,bash]

dig MX domusdigitalis.dev +short dig TXT domusdigitalis.dev +short dig AAAA cloudflare.com +short

==== TLS Certificate Inspection

.Full cert subject, issuer, and validity
[source,bash]

openssl s_client -connect cloudflare.com:443 </dev/null 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates

.Full chain with SANs
[source,bash]

openssl s_client -connect cloudflare.com:443 -showcerts </dev/null 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

.Verify ISE portal cert (Let's Encrypt)
[source,bash]

openssl s_client -connect ise-01.inside.domusdigitalis.dev:8443 </dev/null 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates

.Check cert expiry only (days remaining)
[source,bash]

openssl s_client -connect cloudflare.com:443 </dev/null 2>/dev/null \ | openssl x509 -noout -enddate

.Verify cert against specific CA
[source,bash]

openssl s_client -connect ise-01.inside.domusdigitalis.dev:443 \ -CAfile ~/.secrets/certs/d000/ise/ROOT-CA.crt </dev/null 2>/dev/null \ | head -5

==== HTTP Reachability

.Status code only (silent)
[source,bash]

curl -sk -o /dev/null -w '%{http_code}\n' cloudflare.com curl -sk -o /dev/null -w '%{http_code}\n' ise-01.inside.domusdigitalis.dev:443/api/v1/

.Follow redirects + show final URL
[source,bash]

curl -skL -o /dev/null -w '%{url_effective} → %{http_code}\n' cloudflare.com

.Response headers
[source,bash]

curl -skI cloudflare.com | head -10

.Time breakdown (DNS, connect, TLS, total)
[source,bash]

curl -sk -o /dev/null -w 'dns: %{time_namelookup}s\nconnect: %{time_connect}s\ntls: %{time_appconnect}s\ntotal: %{time_total}s\n' cloudflare.com

==== TCP Connectivity

.Port reachability (no HTTP)
[source,bash]

nc -zv 10.50.1.20 9060 2>&1 nc -zv 10.50.1.20 8443 2>&1 nc -zv 10.50.1.40 443 2>&1

.Multiple ports in one pass
[source,bash]

for port in 443 8443 9060 1812; do nc -zv -w2 10.50.1.20 "$port" 2>&1 | grep -E 'succeeded|refused|timed' done

==== ICMP + Routing

.Basic reachability with latency
[source,bash]

ping -c 3 -W 2 10.50.30.1

.Trace path to internet from guest VLAN
[source,bash]

traceroute -n 1.1.1.1

.MTU discovery (catch PMTU issues)
[source,bash]

ping -c 3 -M do -s 1472 10.50.30.1

==== RADIUS

.Test RADIUS auth from WLC perspective (from ISE debug)
[source,bash]

curl -sk -u "${ISE_API_USER}:${ISE_API_PASS}" \ -H "Accept: application/json" \ "https://${ISE_PAN_FQDN}:9060/ers/config/node" \ | jq '.SearchResult.resources[].name'

==== Guest SSID End-to-End Validation Sequence

.Run from a device on the guest network (10.50.30.x)
[source,bash]

1. Confirm DHCP worked

ip addr show | grep '10.50.30'

2. DNS resolves (through VyOS)

dig +short cloudflare.com

3. Gateway reachable

ping -c 2 10.50.30.1

4. ISE portal reachable (pre-auth — redirect ACL permits this)

curl -sk -o /dev/null -w '%{http_code}\n' ise-01.inside.domusdigitalis.dev:8443

5. Internet blocked pre-AUP (CWA redirect)

curl -sk -o /dev/null -w '%{http_code} %{redirect_url}\n' detectportal.firefox.com/canonical.html

6. Internal VLANs unreachable (firewall isolation)

ping -c 1 -W 1 10.50.1.20 && echo 'FAIL: MGMT reachable' || echo 'PASS: MGMT blocked' ping -c 1 -W 1 10.50.10.1 && echo 'FAIL: DATA reachable' || echo 'PASS: DATA blocked' ping -c 1 -W 1 10.50.40.1 && echo 'FAIL: IOT reachable' || echo 'PASS: IOT blocked'

=== Ad-Hoc Requests

// Concern: Alex's Windows machine — no internet, DHCP triage from VyOS
// Partial: partials/worklog/daily-notes/2026-06/2026-06-21/alex-internet-triage.adoc

==== Alex — Internet Connectivity Issue (In Progress)

Alex's Windows computer has no internet. Triage started from VyOS firewall console.

===== Findings So Far

.DHCP leases — Alex has NO active lease
[source,console]

vyos@vyos-01:~$ show dhcp server leases IP Address MAC address State Lease start Lease expiration Remaining Pool Hostname Origin 10.50.10.117 8c:88:81:2a:00:00 active …​ …​ …​ DATA c9130ax-01 local

Only one lease active (the AP). Zero leases on the IOT pool.

.DHCP pool statistics — IOT pool exists, 0 leases
[source,console]

Pool Size Leases Available Usage DATA 100 1 99 1% GUEST 100 0 100 0% IOT 100 0 100 0% VOICE 100 0 100 0%

.IOT DHCP pool config — pool is correctly configured
[source,console]

IOT subnet: 10.50.40.0/24 Gateway: 10.50.40.1 DNS: 8.8.8.8, 1.1.1.1 (public DNS, not BIND — intentional for IoT) Range: 10.50.40.100 - 10.50.40.199 Lease: 86400 (24h)

.ARP table — devices on VLAN 40 that DO have connectivity

10.50.40.144 a0:59:50:c9:92:ee DELAY (active — many NAT sessions) 10.50.40.103 64:32:a8:c4:c7:19 STALE

NOTE: `10.50.40.144` has hundreds of active NAT translations — so VLAN 40 internet access works. The problem is specific to Alex's machine, not the VLAN.

===== Next Steps — Resume Here

.Step 1: Get Alex's current IP config from PowerShell
[source,powershell]

ipconfig /all

If `169.254.x.x` (APIPA) → machine is not receiving DHCP. Problem is between the machine and VyOS.

If valid `10.50.40.x` IP → machine has DHCP but can't reach the internet. Check gateway and DNS.

.Step 2: Test layer by layer from Alex's PowerShell
[source,powershell]

Can she reach the gateway?

ping 10.50.40.1

Can she reach the internet (bypass DNS)?

ping 8.8.8.8

Can she resolve DNS?

nslookup google.com Resolve-DnsName google.com

Trace the path

tracert 8.8.8.8

.Step 3: If no DHCP — force a renewal
[source,powershell]

ipconfig /release ipconfig /renew

.Step 4: Check which SSID she's connected to
[source,powershell]

netsh wlan show interfaces

If she's on Domus-Secure (802.1X) instead of Domus-IoT (iPSK), she needs an ISE-issued certificate to authenticate — which she won't have. Should be on HomeRF or Domus-IoT.

===== VyOS Commands Used

[source,bash]

show dhcp server leases show dhcp server statistics show configuration commands | grep dhcp show nat source translations show arp show dns forwarding statistics show firewall statistics

// Session: Ad-Hoc Requests — 2026-06-21
// Included by: daily-notes/2026-06/2026-06-21.adoc

==== Ad-Hoc Requests

_Capture walk-ups, Teams pings, and unplanned work here._

*

---

// Worklog Section: Work (CHLA) — Assembler
// Usage: include::partial$worklog/work-chla.adoc[]
// Contains: All work domains via sub-partials
//
// PARADIGM: Each concern = its own file in work/
// FILES: timekeeping.adoc, projects.adoc, priorities.adoc, tickets.adoc
//
// MAINTENANCE: Comment out sections for weekend/non-work worklogs
// Weekend: comment out timekeeping + tickets, keep projects + priorities

== Work (CHLA)

// Worklog Work: Timekeeping
// Usage: Included by worklog/work-chla.adoc assembler
// Contains: PeopleSoft time entry reminder

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

xref:projects/chla/PRJ-peoplesoft-time-entry.adoc[PeopleSoft Time Entry Reference]

// Worklog Work: Projects
// Usage: Included by worklog/work-chla.adoc assembler
// Contains: P0/P1/P2 project priorities + case study links

// Critical Projects (P0) — Blocking or critical priority
// Usage: include::partial$trackers/work/projects/p0.adoc[]
// Last updated: 2026-05-12

=== Critical (P0)

[cols="2,3,1,1,1,2"]
|===
| 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 Projects (P1) — Important but not blocking
// Usage: include::partial$trackers/work/projects/p1.adoc[]
// Last updated: 2026-04-22

=== High Priority (P1)

[cols="2,3,1,1,1"]
|===
| 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 Projects (P2) — Long-term or not yet started
// Usage: include::partial$trackers/work/projects/p2.adoc[]
// Last updated: 2026-04-22

=== Strategic (P2)

[cols="2,3,1,1"]
|===
| 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
|===

// Case Study Links — TAC, incidents, changes, RCAs
// Usage: include::partial$trackers/work/links/case-studies.adoc[]
// Last updated: 2026-04-04

==== Case Studies (March 2026)

**TAC Cases:**

* xref:case-studies/tac/chla-8021x-auth-failures/index.adoc[TAC-2026-03 - 802.1X Auth Failures]

**Incidents:**

* xref:case-studies/incidents/strongline-gateway-vlan/index.adoc[INC - Strongline Gateway VLAN]
* xref:case-studies/incidents/ise-incident-defense/index.adoc[PREP - ISE Incident Defense]

**Changes:**

* xref:case-studies/changes/vault-backup-selinux/index.adoc[CR - Vault Backup SELinux]

**RCAs:**

* xref:case-studies/rca/8021x-eaptls-ca-chain/index.adoc[RCA - 802.1X EAP-TLS CA Chain]
* xref:case-studies/rca/wifi-dhcp-failure/index.adoc[RCA - WiFi DHCP Failure]

// Worklog Work: Daily Priorities
// Usage: Included by worklog/work-chla.adoc assembler
// Contains: Today's actionable priority checkboxes

=== Today's Priorities

// Current Priorities — Persistent P0/P1 tracker
// Usage: include::partial$trackers/work/priorities/current.adoc[]
// Last updated: 2026-06-09
//
// RULES:
// - No dated sections (morning/meeting checklists go in the WORKLOG, not here)
// - Items stay until YOU confirm completion, then move to === Completed with date
// - Day counts use {origin-*} attributes from antora.yml — update-days recalculates
// - New work items from d001/ get added here during daily triage
// - Review weekly: anything resolved? anything to add?

=== 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: {origin-monad}) — 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).
* [x] **MSCHAPv2 weekly cadence** — recurring Wednesday call established (first 04-22). Completed 2026-04-22.
* [x] **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)

* [x] **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.
* [x] **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: {origin-k3s-nat}). **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: {origin-strongline}). **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: {origin-k3s-nat})
* [ ] **Vocera EAP-TLS Supplicant Fix** (origin: {origin-vocera})
* [ ] **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)

// Move items here with completion date when YOU verify they're done.
// Format: * [x] **Item** — completed YYYY-MM-DD

* [x] **CR-2026-04-15 SRT Research VLAN** — submitted to iTrack. Completed 2026-04-15.
* [x] **CAB presentation 4/21** — SRT Research VLAN 233 → CHLA-Research. APPROVED. Completed 2026-04-21.
* [x] **Downtime Computers wireless audit** — 45 computers, 16 violating, v3 report delivered. Completed 2026-04-21.
* [x] **Git identity fix** — dots-quantum/git/.gitconfig email corrected. Completed 2026-04-21.
* [x] **MSCHAPv2 10:30 meeting** — next steps + ACL coordination. Completed 2026-04-17.

// Worklog Work: ITSM Tickets
// Usage: Included by worklog/work-chla.adoc assembler
// Contains: Active service requests, incidents, and change requests

=== Active Tickets

// Service Requests — SR ticket tracking
// Usage: include::partial$trackers/work/itsm-tickets/service-requests.adoc[]
// Last updated: 2026-04-30

=== Service Requests (SR)

[cols="1,2,2,1,1"]
|===
| 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 ticket tracking
// Usage: include::partial$trackers/work/itsm-tickets/incidents.adoc[]
// Last updated: 2026-04-30

=== Incidents (INC)

[cols="1,1,2,1,1,1"]
|===
| INC# | Priority | Description | Opened | SLA | Status

| 1911859
| —
| Strongline Gateways in Miscellaneous Subnet
| —
| —
| STALE — verify in iTrack (related to carryover P0)

|===

// Emergency Changes — ECAB change request tracking
// Usage: include::partial$trackers/work/itsm-tickets/changes-emergency.adoc[]
// Last updated: 2026-04-30

=== Change Requests - Emergency (ECAB)

[cols="1,2,1,1,1"]
|===
| CR# | Description | Opened | Scheduled | Status

| _No emergency changes_
|
|
|
|

|===

// Normal Changes — Standard change request tracking
// Usage: include::partial$trackers/work/itsm-tickets/changes-normal.adoc[]
// Last updated: 2026-04-30

=== Change Requests - Normal

[cols="1,2,1,1,1"]
|===
| CR# | Description | Opened | Scheduled | Status

| _No normal changes_
|
|
|
|

|===

// Scheduled Changes — Scheduled/standard change request tracking
// Usage: include::partial$trackers/work/itsm-tickets/changes-scheduled.adoc[]
// Last updated: 2026-04-30

=== Change Requests - Scheduled/Standard

[cols="1,2,1,1,1"]
|===
| CR# | Description | Opened | Window | Status

| _No scheduled changes_
|
|
|
|

|===

// RCA Changes — Root cause / post-incident change request tracking
// Usage: include::partial$trackers/work/itsm-tickets/changes-rca.adoc[]
// Last updated: 2026-04-30

=== Change Requests - Root Cause / Post-Incident

[cols="1,2,1,1,1"]
|===
| 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._

---

// Worklog Section: Personal
// Usage: include::partial$worklog/personal.adoc[]
// Contains: Personal projects, adhoc items, reference links

== Personal

// In Progress Projects
// Usage: include::partial$trackers/personal/projects/active.adoc[]
// Last updated: 2026-04-04

=== In Progress

[cols="2,3,1,2"]
|===
| 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 Projects
// Usage: include::partial$trackers/personal/projects/planned.adoc[]
// Last updated: 2026-04-30

=== Planned

[cols="2,3,1,2"]
|===
| 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
| --

| xref:projects/personal/domus-inventory/index.adoc[Domus Inventory]
| Personal asset management (YAML + CLI + AsciiDoc)
| Q2 2026
| Schema approved
|===

// Active — Infrastructure
// Usage: include::partial$trackers/personal/tasks/active-infrastructure.adoc[]
// Last updated: 2026-06-18

=== Active — Infrastructure

[cols="2,3,1,1,1"]
|===
| 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
// Usage: include::partial$trackers/personal/tasks/active-security.adoc[]
// Last updated: 2026-04-04

=== Active — Security & Encryption

[cols="2,3,1,1,1"]
|===
| 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
// Usage: include::partial$trackers/personal/tasks/active-development.adoc[]
// Last updated: 2026-04-04

=== Active — Development & Tools

[cols="2,3,1,1,1"]
|===
| 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
// Usage: include::partial$trackers/personal/tasks/active-docs.adoc[]
// Last updated: 2026-04-04

=== Active — Documentation

[cols="2,3,1,1,1"]
|===
| Task | Details | Priority | Status | Due

| **D2 Catppuccin Mocha styling**
| domus-* spoke repos (177 files total)
| P3
| In Progress
| --

|===

'''

// Active — Financial
// Usage: include::partial$trackers/personal/tasks/active-financial.adoc[]
// Last updated: 2026-04-04

=== Active — Financial

[cols="2,3,1,1,1"]
|===
| 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
// Usage: include::partial$trackers/personal/tasks/active-education.adoc[]
// Last updated: 2026-04-04

=== Active — Education

[cols="2,3,1,1,1"]
|===
| Task | Details | Priority | Status | Due

| _No active education tasks — see education trackers_
|
|
|
|

|===

'''

// Active — Personal & Life Admin
// Usage: include::partial$trackers/personal/tasks/active-personal.adoc[]
// Last updated: 2026-04-04

=== Active — Personal & Life Admin

[cols="2,3,1,1,1"]
|===
| Task | Details | Priority | Status | Due

| **ThinkPad T16g Setup**
| Arch install, stow dotfiles, Ollama stack, netapi dev env
| P0
| Pending
| --

| **P50 Arch to Ubuntu migration**
| xref:case-studies/changes/p50-arch-to-ubuntu/index.adoc[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 Quick Links
// Usage: include::partial$trackers/personal/links/sites.adoc[]
// Last updated: 2026-04-04

==== Documentation Sites

* https://docs.domusdigitalis.dev/[docs.domusdigitalis.dev] - Private documentation hub
* https://docs.architectus.dev/[docs.architectus.dev] - Public portfolio site

=== Notes

_Day-specific personal notes here._

---

// Worklog Section: Education — Assembler
// Usage: include::partial$worklog/education.adoc[]
// Contains: All education domains via sub-partials
//
// PARADIGM: Each domain = its own file in education/
// FILES: ai-engineering.adoc, languages.adoc, study-today.adoc, regex.adoc
//
// MAINTENANCE: Add/remove domains by editing includes below
// To add RHCSA: include::partial$worklog/education/rhcsa.adoc[]

== Education

// Worklog Education: AI Engineering
// Usage: Included by worklog/education.adoc assembler
// Contains: Claude Code + AI training status

=== Claude Code + AI Engineering (ACTIVE)

=== Claude Code Mastery

[cols="2,3,1,1"]
|===
| 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

|===

=== Active Tracks (Focus)

* xref:education/systems/regex-mastery.adoc[Regex Mastery] | xref:education/systems/regex/index.adoc[Curriculum]
* xref:education/rhcsa/index.adoc[RHCSA 9]
* xref:education/literature/don-quijote.adoc[Don Quijote] - Primera Parte
* xref:education/languages/dele-spanish.adoc[DELE C1/C2]

=== Skills Mastery (Critical)

* xref:education/systems/regex/index.adoc[Regex Mastery] - 10-module curriculum
* xref:education/programming/python.adoc[Python Mastery]
* xref:education/programming/bash.adoc[Bash Mastery]
* https://docs.asciidoctor.org/asciidoc/latest/[AsciiDoc Docs^] - Documentation format
* https://antora.org/[Antora Docs^] - Documentation pipeline

=== 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)

// Worklog Education: Languages
// Usage: Included by worklog/education.adoc assembler
// Contains: DELE/SIELE certs, Don Quijote writing method

=== Language Certifications (DELE/SIELE)

=== Spanish C1 Certification Goals

[cols="2,2,1,1,2"]
|===
| Certification | Provider | Target | Status | Strategy

| xref:education/languages/siele.adoc[**SIELE C1**]
| https://siele.org/[Instituto Cervantes^] / UNAM / Salamanca
| **Q2 2026**
| ACTIVE
| Computer-based, faster results - take FIRST

| xref:education/languages/dele-spanish.adoc[**DELE C1**]
| https://examenes.cervantes.es/es/dele/que-es[Instituto Cervantes^]
| **Q3/Q4 2026**
| PLANNED
| After SIELE success, harder exam

| xref:education/languages/dele-spanish.adoc[**DELE C2**]
| https://examenes.cervantes.es/es/dele/que-es[Instituto Cervantes^]
| 2027
| FUTURE
| Mastery level - requires extensive immersion

|===

TIP: 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

// Worklog Education: Today's Study
// Usage: Included by worklog/education.adoc assembler
// Contains: Current study focus pointer

=== 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

// Worklog Education: Regex Training
// Usage: Included by worklog/education.adoc assembler
// Contains: Regex training status (remove when complete)

=== Regex Training (CRITICAL)

* **Status:** 52 days carried over (since 2026-03-16)
* **Priority:** After PeopleSoft, before Quijote
* **Session:** Character classes, word boundaries

---

// Worklog Section: Infrastructure
// Usage: include::partial$worklog/infrastructure.adoc[]
// Contains: Infrastructure sites, HA status, SPOFs, validation

== Infrastructure

// Documentation Sites
// Usage: include::partial$trackers/personal/infrastructure/sites.adoc[]
// Last updated: 2026-04-04

=== Documentation Sites

[cols="2,2,1,2"]
|===
| Site | URL | Status | Actions Needed

| **Domus Digitalis**
| https://docs.domusdigitalis.dev[docs.domusdigitalis.dev]
| Active
| Validate, harden, improve

| **Architectus**
| https://docs.architectus.dev[docs.architectus.dev]
| Active
| Public portfolio site - maintain

|===

// HA Deployment Status
// Usage: include::partial$trackers/personal/infrastructure/ha-status.adoc[]
// Last updated: 2026-04-04

=== HA Deployment Status

[cols="2,2,1,2"]
|===
| 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
// Usage: include::partial$trackers/personal/infrastructure/spof.adoc[]
// Last updated: 2026-04-04

=== Single Points of Failure (CRITICAL)

WARNING: These systems have NO redundancy - outage impacts production.

[cols="2,2,3"]
|===
| 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
// Usage: include::partial$trackers/personal/infrastructure/validation.adoc[]
// Last updated: 2026-04-04

=== Validation Tasks

[cols="2,3,1"]
|===
| 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

|===

---

// Worklog Section: Quick Commands — Assembler
// Usage: include::partial$worklog/quick-commands.adoc[]
// Sub-partials: commands/<category>.adoc
//
// PARADIGM: Each tool domain = its own file in commands/
// Add new categories by adding an include below.
// Graduate mature entries to codex/ pages.
//
// MAINTENANCE: Add new commands to the appropriate sub-partial.
// Split when a sub-partial exceeds ~150 lines.

== Quick Commands

// Commands: Git & GitHub CLI
// Graduated from: .drafts/domus-terminal-workflows-2026-04-23.adoc
// Promote mature entries to: codex/git/

=== Git & GitHub CLI

.create GitHub repo from existing local repo
[source,bash]

gh repo create <name> --private --source . --remote origin --push

.clone a forked repo into a specific directory
[source,bash]

gh repo clone EvanusModestus/PowerShell ~/atelier/_projects/work/PowerShell

NOTE: `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
[source,bash]

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
[source,bash]

git log --oneline — $(find . -name "*.adoc" -type f -newermt "$(date +%F)")

.unstage a file without losing changes
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

gh repo clone EvanusModestus/<repo-name> ~/atelier/_bibliotheca/<repo-name>

// Commands: find & grep
// Graduated from: .drafts/domus-terminal-workflows-2026-04-23.adoc, find-discovery-2026-04-18.adoc
// Promote mature entries to: codex/cli/find.adoc, codex/cli/grep.adoc

=== find & grep

.files modified since midnight today (precise — not "last 24 hours")
[source,bash]

find . -name "*.adoc" -type f -newermt "$(date +%F)" | sort

NOTE: `-mtime 0` means "last 24 hours", not "today". `-newermt "$(date +%F)"` compares against midnight — exact.

.case-insensitive file search
[source,bash]

find . -iname "mschap" -type f | sort

.multiple name patterns with -o
[source,bash]

find . -type f \( -iname "ise" -o -iname "mschap" \) | sort

.same thing, single regex — fewer parens, extensible
[source,bash]

find . -type f -iregex '.\(ise\|mschap\).'

.exclude directories
[source,bash]

find . -type f -iname "meeting" \ -not -path "/node_modules/" \ -not -path "/.git/" \ -not -path "/build/"

.recent drafts by modification time (newest first)
[source,bash]

find .drafts -type f -printf '%T@ %Tc %p\n' | sort -rn | awk '{$1="";print}' | head -3

.grep — know what you're counting
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

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)
[source,bash]

find -path 'oauth' -name '*.adoc' -type f \ -exec grep -l 'timeout\|expire' {} \; \ -exec nvim {} \;

WARNING: 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)
[source,bash]

grep -rl 'commands/shell' docs/modules/ROOT/partials/

.count all pages that include a partial
[source,bash]

grep -rl 'quick-commands' docs/modules/ROOT | wc -l

.full chain: partial → assembler → every page that uses it
[source,bash]

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
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

grep -rn --include='*.adoc' -c 'sanchuelo' . | grep -v ':0$'

.2. Cross-repo — filenames only (all bibliotheca)
[source,bash]

grep -rl --include='*.adoc' -i 'sanchuelo' ~/atelier/_bibliotheca/ | sort

.3. Cross-repo with context — see the line in situ
[source,bash]

grep -rn --include='*.adoc' -i -B1 -A1 'sanchuelo' ~/atelier/_bibliotheca/domus-captures/

.4. Multi-filetype — .adoc + .txt (catches source texts)
[source,bash]

grep -rl -i 'sanchuelo' ~/atelier/_bibliotheca/ --include='.txt' --include='.adoc' | sort

.5. Null-safe find + xargs — handles spaces in paths
[source,bash]

find ~/atelier/_bibliotheca/ -type f \( -name '.adoc' -o -name '.txt' \) -print0 \ | xargs -0 grep -li 'sanchuelo' | sort

.6. Open all hits directly in nvim
[source,bash]

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?
[source,bash]

Find files — filenames only

grep -rl 'build-antora-page' docs/modules/ROOT/partials/worklog/

.see the match in context without opening the file
[source,bash]

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)
[source,bash]

grep -rPn 'theme\s+(light-cyan|catppuccin|mocha)' docs/modules/ROOT/partials/

.find + grep — search all daily note partials for build/export patterns
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

find docs/modules/ROOT/partials/worklog -name '*.adoc' -exec \ sed -n '/\[source,bash\]/,/^----$/{/build-antora-page/p}' {}

.search data/ too — standalone adoc outside Antora
[source,bash]

grep -rn 'build-antora-page\|build-adoc' data/ --include='*.adoc'

.every invocation sorted by path (chronological by date directory)
[source,bash]

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)
[source,bash]

grep -P '(@\w+|^From:.*<)' comms.adoc

.timeline — every date with context
[source,bash]

grep -nP '\d{1,2}/\d{1,2}/\d{2,4}|20\d{2}-\d{2}-\d{2}' comms.adoc

.commitments — who promised what
[source,bash]

grep -niP '(I can |I will |I.ll |we will |we.ll )' comms.adoc

.open questions and unknowns
[source,bash]

grep -niP '(\?|need to confirm|need to validate|TBD|pending)' comms.adoc

==== comm — set difference (who hasn't replied)

[source,bash]

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.

WARNING: `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)
[source,bash]

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
[source,bash]

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
[source,bash]

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)
[source,bash]

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
[source,bash]

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)
[source,bash]

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
[source,bash]

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?
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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)

// Commands: awk, sed, jq — Text Processing
// Graduated from: .drafts/terminal-mastery-session-2026-04-19.adoc,
//   .drafts/domus-terminal-workflows-2026-04-22.adoc
// Promote mature entries to: codex/awk/, codex/jq/

=== awk, sed, jq

==== awk — field extraction

.print second field (whitespace-delimited)
[source,bash]

awk '{print $2}' file.txt

.custom delimiter — colon-separated (like /etc/passwd)
[source,bash]

awk -F: '{print $1, $3}' /etc/passwd

.extract JSON code blocks from AsciiDoc
[source,bash]

awk '/\[source,json\]/{getline; if ($0 ~ /^----/) {p=1; next}} p && /^----/{p=0; next} p' file.adoc

.field extraction with printf formatting
[source,bash]

awk '{printf "%-30s %s\n", $1, $2}' file.txt

==== sed — stream editing

.in-place replacement with verify-before/after
[source,bash]

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
[source,bash]

sed -n '10,20p' file.txt

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

.the full pattern: locate → validate → change → verify
[source,bash]

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
[source,bash]

awk 'NR==1218 || NR==1760' zsh/.zshrc

.range extraction — NR for surgical reads from large files
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

curl -s localhost:8080/stats | jq '.stats.total_files'

.filter array by property
[source,bash]

jq '.results[] | select(.category == "standards")' response.json

.transform to TSV for spreadsheets
[source,bash]

jq -r '.[] | [.title, .path] | @tsv' response.json | column -t -s $'\t'

.GitHub API + jq — commit history by path
[source,bash]

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])"'

// Commands: Shell Patterns (bash/zsh)
// Graduated from: .drafts/domus-terminal-workflows-2026-04-23.adoc
// Promote mature entries to: codex/bash/

=== Shell Patterns

==== xargs — when the next command reads arguments, not stdin

[cols="1,2"]
|===
| 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
[source,bash]

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
[source,bash]

find .drafts -name "*.adoc" -type f | xargs -P4 -I{} asciidoctor -o /dev/null {}

.null-delimited pipeline — safe for filenames with spaces
[source,bash]

find . -name "*.adoc" -type f -print0 | xargs -0 wc -l

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

.compare tracker state: yesterday vs today
[source,bash]

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
[source,bash]

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
[source,bash]

nvim "$(find data/ -name '*.adoc' -type f -printf '%T@ %p\n' | sort -rn | awk 'NR==1{print $2}')"

.line count across a project
[source,bash]

wc -l $(find docs/modules/ROOT -path 'mschapv2' -name '*.adoc' -type f)

==== Conditional execution — capture, test, act

.open matching files only if they exist
[source,bash]

files=$(find .drafts -name 'in*' -type f) && [ -n "$files" ] && nvim $files

.open files that contain unchecked items
[source,bash]

files=$(grep -rl '\[ \]' .drafts/*.adoc) && [ -n "$files" ] && nvim $files

.guard with grep -q — only act if pattern matches
[source,bash]

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

[source,bash]

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

[source,bash]

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

[source,bash]

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
[source,bash]

printf '---\n\n'

.right — %s format string treats `---` as data
[source,bash]

printf '%s\n\n' '---'

==== Kill stuck SSH sessions

.Find established SSH connections
[source,bash]

lsof -i TCP -n -P | awk '/ssh.*ESTABLISHED/ {print $2, $9}'

.Kill all stuck SSH sessions to a specific host
[source,bash]

lsof -i TCP -n -P | awk '/ssh.*kvm-01.*ESTABLISHED/ {print $2}' | sort -u | xargs kill

.Kill ALL stuck SSH sessions
[source,bash]

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.

// Commands: File Descriptors & Redirection
// Graduated from: .drafts/terminal-mastery-session-2026-04-19.adoc
// Promote mature entries to: codex/bash/

=== File Descriptors & Redirection

==== The three file descriptors

[cols="1,1,3"]
|===
| 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

[source,bash]

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

==== Suppress errors — `2>/dev/null`

[source,bash]

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

==== Merge stderr into stdout — `2>&1`

[source,bash]

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
[source,bash]

cat <<'EOF' Line 1 Line 2 EOF

.heredoc commit messages (quotes prevent variable expansion)
[source,bash]

git commit -m "$(cat <<'EOF' feat: add new feature

Multi-line description here. EOF )"

// Commands: API & curl/jq
// Graduated from: quick-commands.adoc (existing)
// Promote mature entries to: codex/fastapi/, codex/jq/

=== API & curl/jq

==== domus-api — Documentation System REST API

.start the API server
[source,bash]

cd ~/atelier/_projects/personal/domus-api && uv run uvicorn domus_api.main:app --host 0.0.0.0 --port 8080

.health check
[source,bash]

curl -s localhost:8080/ | jq

.full-text search
[source,bash]

curl -s 'localhost:8080/search?q=mandiant' | jq

.search — extract path, title, match count
[source,bash]

curl -s 'localhost:8080/search?q=mandiant' | jq '.results[] | {path, title, match_count}'

.list pages by category
[source,bash]

curl -s 'localhost:8080/pages?category=standards' | jq

.all antora.yml attributes
[source,bash]

curl -s localhost:8080/attributes | jq

==== GitHub API

.cross-repo search via GitHub API
[source,bash]

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
[source,bash]

gh api 'repos/EvanusModestus/domus-captures/git/trees/main?recursive=1' | jq '[.tree[] | select(.path | endswith(".adoc"))] | length'

// Commands: Domus Documentation System — Terminal Workflows
// Graduated from: .drafts/domus-terminal-workflows-2026-04-21.adoc
// Promote mature entries to: codex/documentation/

=== Domus Workflows

==== Read content from terminal (meeting-ready)

.today's worklog
[source,bash]

bat docs/modules/ROOT/pages/2026/04/WRKLOG-$(date +%Y-%m-%d).adoc

.current priorities
[source,bash]

bat docs/modules/ROOT/partials/trackers/work/priorities/current.adoc

.carryover backlog
[source,bash]

bat docs/modules/ROOT/partials/trackers/work/adhoc/carryover.adoc

.any project summary
[source,bash]

bat docs/modules/ROOT/partials/projects/mandiant-remediation/summary.adoc

==== Search and discovery

.find all files related to a topic
[source,bash]

grep -rl "MSCHAPv2" docs/modules/ROOT/ --include="*.adoc" | sort

.search codex entries
[source,bash]

grep -rn "pattern" docs/modules/ROOT/partials/codex/ --include="*.adoc" -B1 -A3

.list all worklogs for a month
[source,bash]

ls -1 docs/modules/ROOT/pages/2026/04/WRKLOG-*.adoc

==== Tracker aging — calculate days from origin

.how many days since a carryover item started
[source,bash]

echo $($(date +%s) - $(date -d "2026-03-09" +%s / 86400 ))

==== Encrypted data access (d001)

.view encrypted file without disk write
[source,bash]

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
[source,bash]

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
[source,bash]

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)
[source,bash]

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
[source,bash]

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
[source,bash]

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)
[source,bash]

lp data/d000/education/quijote-study/notas/p1-cap-03{7,8,9}/output/*.pdf

.build LPL logic study (English / Spanish)
[source,bash]

d000 build annotated-text pdf --theme light-cyan d000 build lpl-study/notas/texto-anotado pdf --theme light-cyan

.build Cicero study
[source,bash]

d000 build de-oratore/libro-i/texto-anotado html --variant light-cyan

==== Available themes

.PDF themes (--theme)
[source,bash]

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)
[source,bash]

~/atelier/_bibliotheca/domus-asciidoc-build/docinfo/compose.sh --list # light dark catppuccin royal light-cyan

// Commands: ISE & Network Operations
// Graduated from: .drafts/terminal-mastery-session-2026-04-19.adoc,
//   data/d000/infra/ references
// Promote mature entries to: codex/networking/, codex/security/

=== ISE & Network Ops

==== ISE ERS API — endpoint CRUD

.set credentials (session)
[source,bash]

export ISE_HOST="10.50.1.20" ISE_USER="admin" ISE_PASS="$(gopass show -o ise/admin)"

.list identity groups
[source,bash]

curl -sk "https://$ISE_HOST:9060/ers/config/identitygroup" \ -H "Accept: application/json" -u "$ISE_USER:$ISE_PASS" | jq '.SearchResult.resources[].name'

.check if endpoint exists by MAC
[source,bash]

curl -sk "https://$ISE_HOST:9060/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
[source,bash]

openssl x509 -in /etc/ssl/certs/client.pem -text -noout | head -30

.check cert expiry
[source,bash]

openssl x509 -in /etc/ssl/certs/client.pem -enddate -noout

==== Network diagnostics

.check listening ports
[source,bash]

ss -tlnp | grep -E ':443|:22|:636'

.test ISE connectivity
[source,bash]

nc -zv 10.50.1.20 9060

.DNS resolution
[source,bash]

dig ise-01.inside.domusdigitalis.dev +short

==== ISE eval rotation — backup & restore

.backup from ISE CLI (when admin UI is license-locked)
[source,bash]

SSH to ISE

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
[source,bash]

ssh admin@ise-02.inside.domusdigitalis.dev show repository nas-01

.restore to fresh ISE node
[source,text]

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
[source,bash]

show vrrp show configuration commands | grep vrrp | grep 'address'

.firewall zone membership
[source,bash]

show configuration commands | grep 'firewall zone' | grep 'member'

.DHCP leases and ARP
[source,bash]

show dhcp server leases show arp

.full interface/VLAN map
[source,bash]

show interfaces

==== CUPS printing — validation & setup

.software validation
[source,bash]

command -v lpstat && echo "CUPS present" || echo "CUPS not installed" lpstat -r # scheduler running? lpstat -p -d # printers + default

.daemon lifecycle
[source,bash]

sudo systemctl enable --now cups # start + persist

.printer discovery
[source,bash]

lpinfo -v # available backends/URIs lpinfo -m | grep -i <brand> # available drivers

.add printer and set default
[source,bash]

sudo lpadmin -p <name> -v <uri> -m everywhere -E lpoptions -d <name>

.print
[source,bash]

lp file.pdf # default printer lp -d <name> -o sides=two-sided-long-edge file.pdf

// Commands: PowerShell (from zsh)
// Graduated from: .drafts/domus-terminal-workflows-2026-04-23.adoc
// Promote mature entries to: codex/powershell/

=== PowerShell (from zsh)

NOTE: 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
[source,bash]

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
[source,bash]

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
[source,bash]

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 '.'

WARNING: 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
[source,powershell]

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
[source,powershell]
==== WSL ↔ Windows — Cross-Environment Commands

===== From zsh (WSL) — control Windows

.run any PowerShell command from zsh
[source,bash]

pwsh -NoLogo -Command 'Get-Date'

.run multi-line PowerShell from zsh (heredoc)
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,bash]

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
[source,powershell]

wsl -e bash -c 'grep -rn "Ghost-Sender" ~/atelier/_bibliotheca/domus-captures/docs/'

.run a specific WSL command and capture output
[source,powershell]

$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
[source,bash]

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
[source,bash]

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
[source,bash]

pwsh -NoLogo -Command 'Stop-Process -Name "Teams" -Force -ErrorAction SilentlyContinue'

.kill by PID
[source,bash]

pwsh -NoLogo -Command 'Stop-Process -Id 12345 -Force'

.what's listening on a port (Windows equivalent of ss -tulnp)
[source,bash]

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
[source,bash]

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
[source,bash]

pwsh -NoLogo -Command 'Get-Service | Where-Object { $_.Status -eq "Running" } | Sort-Object DisplayName | Format-Table Name, DisplayName, Status -AutoSize'

.check a specific service
[source,bash]

pwsh -NoLogo -Command 'Get-Service -Name "WinRM" | Format-List Name, DisplayName, Status, StartType'

.restart a service
[source,powershell]

Restart-Service -Name "WinRM" -Force

===== System Info — Quick Health from zsh

.one-shot Windows system summary
[source,bash]

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/1MBGB total, $([math]::RoundGet-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1MBGB free" Write-Host "CPU: $Get-CimInstance Win32_Processor).Name)" Write-Host "Disk C: $([math]::Round((Get-PSDrive C).Free/1GBGB free of $([math]::Round(Get-PSDrive C).Used + (Get-PSDrive C).Free)/1GBGB"'

.disk usage — all drives
[source,bash]

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)
[source,bash]

pwsh -NoLogo -Command 'Connect-ExchangeOnline -UserPrincipalName erosado@chla.usc.edu'

NOTE: 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.)
[source,bash]

pwsh -NoLogo # Then inside pwsh: # Connect-ExchangeOnline # Get-TransportRule | Format-List Name, State # exit

===== File Transfer Patterns

.move files between WSL and Windows
[source,bash]

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)
[source,bash]

inotifywait -m /mnt/c/Users/erosado/Downloads -e create -e moved_to | awk '{printf "%s %s\n", strftime("%H:%M:%S"), $3}'

NOTE: `inotifywait` requires `inotify-tools`. Install with `sudo pacman -S inotify-tools` if not present.

// Commands: Security & Encryption
// Graduated from: .drafts/domus-terminal-workflows-2026-04-23.adoc
// Promote mature entries to: codex/security/

=== Security & Encryption

==== View encrypted files without writing to disk

.pipe age decrypt to bat — nothing touches the filesystem
[source,bash]

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
[source,bash]

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

WARNING: 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
[source,bash]

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

[source,bash]

shred -u data/d001/projects/mandiant-remediation/man-report.txt

NOTE: 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

[source,bash]

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

// Commands: System & Infrastructure
// Graduated from: quick-commands.adoc (existing), .drafts/
// Promote mature entries to: codex/

=== System & Infrastructure

==== PipeWire audio validation

[source,bash]

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

[source,bash]

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

[source,bash]

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
[source,bash]

ssh kvm-01 "sudo virsh list --all" ssh kvm-02 "sudo virsh list --all"

.find ISE ISOs across KVM hosts (case-insensitive glob)
[source,bash]

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
[source,bash]

sudo virsh console <vm-name> # Escape: Ctrl+]

.check NAS mount on KVM host
[source,bash]

ssh kvm-01 "mount | grep nas; ls /mnt/"

==== Per-project file dashboard

.per-project summary — total files vs unencrypted plaintext
[source,bash]

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)
[source,bash]

{ 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_

[source,bash]

Check package version and install date (awk field filter)

pacman -Qi curl | awk '/Version|Install Date/'

---

// Worklog Section: Related Documents
// Usage: include::partial$worklog/related.adoc[]
// Contains: Common cross-references for worklogs

== Related Documents

* xref:education/literature/quijote/index.adoc[Don Quijote - Estudio Completo]
* xref:projects/chla/PRJ-peoplesoft-time-entry.adoc[PeopleSoft Time Entry]
* xref:trackers/quarterly-q2-2026.adoc[Q2 2026 Tracker]
* xref:patterns/index.adoc[Pattern Journal]