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 |
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-repoinstalled -
Expressions file reviewed — no false positives (e.g., Don Quijote "Angulo el Malo" is in
segunda-parte/texto/texto-011.adoc— the regex targetsangulo-arreolaspecifically 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: |
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: |
TBD |
In Progress |
Financial discovery — FL-142 preparation |
Gopass Security Audit |
Rotate passwords on shared/known accounts. Add 2FA backup codes to |
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) |
|
2025 xlsx (original, untouched) |
|
Self-eval draft (content source) |
|
LaTeX portfolio 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
-
Team goal (C71) — "In Progress" for second year. Add any metrics work done this year.
-
New individual goal (C66) — CISSP August 2026 date locked?
-
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 |
|
MnT |
REST (HTTPS) |
Read-only — active sessions, auth history, CoA (reauth/disconnect) |
|
DataConnect |
Oracle SQL (Python oracledb) |
Direct DB — full session/endpoint tables, reporting, export |
|
pxGrid |
WebSocket/STOMP |
Pub/sub — real-time session events, profiler data, registration |
|
OpenAPI |
REST (HTTPS) |
Policy CRUD — policy sets, authz/authn rules, conditions (ISE 3.1+) |
|
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 ( |
Create Authorization Policy rule matching TEAP |
OpenAPI ( |
Verify endpoint after auth |
ERS ( |
Check active session |
MnT ( |
Query auth logs for TEAP events |
DataConnect (SQL against |
Real-time session monitoring |
pxGrid (session topic subscription) |
Shell Script Approach
Each API gets its own script. Common patterns extracted to a shared library.
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
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
-
Enumerate current state — list existing policy sets, allowed protocols, authz rules
-
Identify gaps — does an Allowed Protocols list with TEAP exist? What authz rules apply to Linux endpoints?
-
Build TEAP policy — create via OpenAPI if missing
-
Test —
wpa_supplicanton wired interface against ise-01
# 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.
# 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
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:
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"
set -euo pipefail kills interactive shellSourcing 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
${!var} bash indirect expansion fails in zshThe 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:
dsource d000 dev/network/ise
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
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)
Domus_8021X Domus_MAB Default
=== 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
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
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
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_TTLSexists in the protocol list -
Authorization Rules:
Domus_TEAP_Machine_OnlyandDomus_TEAP_Chainingare enabled inDomus_8021X -
Both map to
Domus_Secure_Profile -
Next step: Test
wpa_supplicantEAP-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.
# 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
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"
# }
# }
| API | Accept Header | Conversion |
|---|---|---|
ERS (port 9060) |
|
Native JSON — use |
OpenAPI (port 443) |
|
Native JSON — use |
MnT (port 443) |
|
XML → JSON via |
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 |
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
dsec show d000 dev/network | awk -F= '{print $1}'
dsec show d000 dev/network | grep -i WLC > /tmp/wlc-env.tmp
wc -l /tmp/wlc-env.tmp
awk -F= '{print $1}' /tmp/wlc-env.tmp
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
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
dsec show d000 dev/network/wlc | awk -F= '{print $1}'
dsource d000 dev/network/wlc
# 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 |
|---|---|---|
|
Monolithic — still contains ISE, VyOS, switches, KVM, Ansible |
~100+ |
|
ISE only — ERS, MnT, DataConnect, pxGrid, OpenAPI, mTLS, backup |
45 |
|
FMC only — segmented 2026-06-20 |
8 |
|
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
SELECT COUNT(*) FROM endpoints;
SELECT macAddress, fullName, pskValue, vlan, accountEnabled, lastAccessed
FROM endpoints ORDER BY lastAccessed DESC;
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;
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');
SHOW PROCEDURE STATUS WHERE Db = 'ipsk';
SELECT id, ssidName, description FROM wirelessNetworks;
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.
| Field | Value |
|---|---|
Organization SID |
|
Encryption Key |
Stored in gopass (encrypts sensitive MySQL data at rest) |
DB Host |
|
DB User (app) |
|
DB Name |
|
ISE ODBC User |
|
ISE ODBC Host |
|
ISE ODBC Stored Procedure Mapping
These are configured in ISE under Administration → Identity Management → External Identity Sources → ODBC → Stored Procedures tab.
| ISE Field | Stored Procedure Name |
|---|---|
Plain Text Password Authentication |
|
Plain Text Password Fetching |
|
Check Username or Machine Exists |
|
Fetch Groups |
|
Fetch Attributes |
|
| Standard Procedure | Non-Expired Variant |
|---|---|
|
|
|
|
|
|
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');
-
Administration → Identity Management → External Identity Sources → ODBC
-
Click
iPSKManager(or your ODBC source name) -
Click Test Connection — must show success
-
Go to Stored Procedures tab — verify all 5 procedures are mapped
-
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 |
|
2. What group? |
ISE ERS |
Endpoint group, profile |
|
3. Who owns it? |
iPSK Manager (MariaDB) |
PSK, VLAN, user info |
|
4. What attributes? |
iPSK Manager (MariaDB) |
VLAN assignment, tags |
|
5. Auth history? |
ISE MnT |
Pass/fail, method, timestamp |
|
6. WLC view? |
WLC RESTCONF |
AP, WLAN, signal, client state |
|
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.
v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation
gopass show v3/domains/d000/servers/ipsk-mgr/ipsk-mgr-01/installation | tail -n +2 | yq -r 'keys[]'
# 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][]'
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'
db_pass: 20 chars
ise_db_pass: 20 chars
db_root_pass: 10 chars
ui_pass: 24 chars
# 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 |
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 |
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)
9800-WLC-01
| MAC | AP | WLAN ID | Auth Method |
|---|---|---|---|
|
C9130AX-01 |
4 (Domus-Secure) |
dot1x — |
|
C9130AX-01 |
5 (Domus-IoT) |
MAB (iPSK) — |
|
C9130AX-01 |
4 (Domus-Secure) |
dot1x — |
| Name | Model | IP | Serial | Software |
|---|---|---|---|---|
C9130AX-01 |
CW9166I-B |
10.50.10.117 |
FJC29201LJZ |
17.15.4.160 |
| 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) |
| 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 asmodestus-p16g.inside.domusdigitalis.devon Domus-Secure (WLAN 4) -
iPSK client
a0:59:50:c9:92:ee— authenticated via MAB on Domus-IoT (WLAN 5), hasipsk-tagvalues 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-inforeturnedinvalid-value— the YANG pathCisco-IOS-XE-wireless-oper:wireless-oper-data/wlc-infodoesn’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 |
|
Portal FQDN |
guest.domusdigitalis.dev |
Portal type |
Hotspot (AUP accept, no credentials) |
Guest VLAN |
30 ( |
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 groupDefault 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
-
[x] Confirm VyOS firewall isolates guest (internet-only, no east-west)
-
[ ] WLC: Create WLAN
Domus-Guest(WLAN ID 6, open + MAC filter → ISE-AuthZ) -
[ ] WLC: Create policy profile
POLICY-DOMUS_GUEST(VLAN 30, CWA redirect ACL) -
[ ] WLC: Add WLAN→profile mapping to
default-policy-tagandTAG-DOMUS -
[ ] WLC: Create redirect ACL (
ACL-GUEST-REDIRECT) — permit ISE + DNS, deny all -
[ ] ISE: Verify/create authorization policy rules (MAB unknown → redirect → permit after AUP)
-
[ ] End-to-end test: client → SSID → DHCP → redirect → AUP → internet
Session Closeout
d001 close acme-cert
d001 close cert-renewal
Resume Checklist (Next Session)
-
SSH to 9800-WLC-01 via kvm-01:
ssh kvm-01→ssh 9800-wlc-01 -
Enter config mode:
configure terminal -
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 -
Verify ISE authorization policy rules (API calls in phase-04 partial, Step 5)
-
Test: connect device to
Domus-Guest→ redirect → AUP → internet -
Update phase-04 partial with results
-
d001 close acme-certbefore committing
WLC Configuration (CLI via SSH)
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
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
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
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; }
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'
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.
openssl verify -CAfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem \
/etc/ssl/certs/modestus-p16g-eaptls.pem
/etc/ssl/certs/modestus-p16g-eaptls.pem: OK
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
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.
|
openssl x509 -noout -subject -issuer -enddate -in /etc/ssl/certs/modestus-p16g-eaptls.pem
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
openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/DOMUS-CA-CHAIN.pem | \
openssl pkcs7 -print_certs -noout
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 |
|---|---|---|
|
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
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]