This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Dan Egnor's personal config — both per-user dotfiles and machine-level configuration (system tweaks, nginx, etc.). Driven by pyinfra: deploy.py is the entrypoint, included files do the work.
pyinfra @local deploy.py # this machine, no SSH
pyinfra @local deploy.py --dry # preview without applying
pyinfra eacs.io deploy.py # single remote host over SSH
pyinfra inventory.py deploy.py # whole fleet (when an inventory.py is present)
The first positional arg is the inventory — @local is the local connector, a hostname is treated as an inline single-host inventory, or you can write an inventory.py. Operations files run on the controller; pyinfra ships file content over the wire as needed and never copies the deploy scripts themselves.
Each top-level subdirectory is one area: a setup.py plus a files/ directory containing the content that script puts on the target.
deploy.py— entrypoint. Just a list oflocal.include(...)calls, one per area.nginx/— host-specific (gated onHostname == "egnor-2020"). Manages/etc/nginx/nginx.confwhole-file, the contents of/etc/nginx/sites-enabled/viafiles.sync(delete=True), anacme-challenge.confsnippet under/etc/nginx/snippets/, and the/var/www/letsencryptACME webroot. The Debian sites-available/sites-enabled split is dropped — files go directly to sites-enabled/. Every:443server block includes the ACME snippet so all certs can use the same shared webroot.certbot/— host-specific (same gate). Manages/etc/letsencrypt/cli.ini(setsauthenticator = webrootso certbot never edits nginx) and arenewal-hooks/deploy/reload-nginxscript. The package'scertbot.timerhandles renewal; we just configure what it does.postfix/— host-specific (Hostname == "egnor-2020"). Manages/etc/postfix/{main.cf, master.cf, virtual, aliases_regexp}and/etc/postfix/sasl/smtpd.conf, plus/etc/mailname. Triggerspostmap virtualon source change./etc/aliasesis shipped as an inert comment-only file (postfix'salias_mapspoints only ataliases_regexp, and that file's/.*/ egnor@ofb.netcatch-all already routes every local recipient); the corresponding/etc/aliases.dbis actively removed by the deploy so nothing stale lingers. The cyrus-sasl password DB (sasldb2) is NOT in the repo (live secrets, plaintext-equivalent due to CRAM-MD5/DIGEST-MD5) — see "SASL password workflow" below./etc/sasldb2is a symlink to/var/spool/postfix/etc/sasldb2(the chroot copy) so cyrus-sasl tools, which default to/etc/sasldb2, target the same file postfix reads.opendkim/— host-specific (same gate). Manages/etc/opendkim.confand the three text tables under/etc/dkimkeys/(signing.table,key.table,trusted.hosts). The.privatekeys stay out-of-band (live secrets). Wired into postfix as a milter vialocal:opendkim/opendkim.sock(a unix socket inside postfix's chroot).postsrsd/— host-specific (same gate). Manages/etc/default/postsrsdfor the Sender Rewriting Scheme daemon. Wired into postfix via{sender,recipient}_canonical_maps = tcp:127.0.0.1:{10001,10002}so mail FORWARDED through this host (alias_maps / virtual_alias_maps re-injection) gets its envelope-from rewritten to an SRS-encoded@eacs.ioaddress — preserves SPF alignment at the next hop without forging the original sender.SRS_EXCLUDE_DOMAINSlists every domain whose envelope-from should be left alone: our local mail-receiving domains (eacs.io, approximately.competent.services, blackletterlabs.com, seventeengames.com) PLUSofb.net, because this host is ofb's outbound :25 relay (GCE blocks outbound :25 from ofb), and ofb.net's SPF already authorizes 104.200.25.248 directly. Other ofb-hosted domains (tattoobag.com, etc.) are NOT excluded — when those appear in envelope-from here, it's via ofb-side forwarding of probably-spoofed mail, which is exactly what SRS should rewrite. The HMAC secret at/etc/postsrsd.secretis package-generated on first install (mode 0600, owner=postsrs) and stays out of the repo.postsrsd/is included indeploy.pyBEFOREpostfix/so postsrsd is configured + running before postfix reload activates the canonical_maps lookup.dns/— host-specific (Hostname == "egnor-2020"). Knot DNS authoritative server. Primary for user-owned zones, with source-of-truth zone files at/etc/knot/zones/managed from this repo and Hurricane Electric (ns{1..5}.he.net) as the AXFR-pulling secondary, registered per-zone atdns.he.net. Replaces BIND9 —dns/setup.pystops and disablesnamed.serviceso Knot can claim port 53. Also drops inDNSStubListener=nofor systemd-resolved (and repoints/etc/resolv.confat the non-stub/run/systemd/resolve/resolv.confthat resolved still maintains) so the stub on127.0.0.53doesn't conflict with Knot's0.0.0.0:53bind. Knot's mutable state (journals, slave-zone caches) stays at the package default/var/lib/knot/; primary-zone files inknot.confuse absolute paths into/etc/knot/zones/. Knot bumps SOA serials itself (serial-policy: unixtime,zonefile-load: difference-no-serial,journal-content: all) so primary zone files can keep1as the serial forever.netdata/— Netdata config. Parent vs child role picked by hostname (egnor-2020is the parent; everywhere else is a child streaming up to it). On Linux, managesnetdata.conf+stream.conf(config dir/etc/netdata), plus thego.d/andhealth.d/overrides, and installssmartmontoolsso the go.dsmartctlcollector reports SMART disk health on physical hosts; alerts are evaluated on the parent (files.parent/health.d/) since children run with[health] enabled = no. Fleet-wide alerts beyond SMART: failed systemd service units (health.d/systemdunits.conf— netdata ships this template disabled via a match-nothingunit_name=!*selector; our same-named file replaces the stock one and enables it) and unattended-upgrades freshness (go.d/filecheck.confon parent+children watches/var/lib/apt/periodic/upgrade-stamp, touched only on successful u-u runs;health.d/apt_upgrade.confalerts on stale or never-created stamps — needed becauseapt.systemd.dailyswallows u-u's exit code, so u-u failures never fail the systemd unit).user/— per-user dotfiles, gated onOs == "Linux"(skips BSD, OS X, and other non-Linux).setup.pysymlinks every leaf underuser/files-linux/into the target's$HOME, plususer/files-linux-modern/on Ubuntu 20.04+ (tools whose prebuilt binaries need a recent glibc — mise conf.d, the LazyVim nvim config).user/copy-files/holds the few files that must be copied not linked (e.g..forward). A "leaf" is a regular file, a symlink, or a directory containing.git(the latter two are linked as a unit, not recursed into). Probes~/source/dotfilesand~/dotfilesfor an existing checkout (and clones to~/dotfilesotherwise).tweaks/— root-owned/etc/ systemd drop-ins, gated on facts (LinuxName, etc.) so the file is safe to run on any host — inapplicable tweaks just skip. Each tweak:files.putfollowed bysystemd.daemon_reload+systemd.servicechained via_if=op.did_changeso reloads only happen on real changes.
- Make the document root if needed:
sudo mkdir -p /home/egnor/www-<name> && sudo chown egnor:egnor /home/egnor/www-<name>. - Add a
:443server { ... }block to a file undernginx/files/sites-enabled/. Include the ACME snippet at the top:include /etc/nginx/snippets/acme-challenge.conf;. Reference/etc/letsencrypt/live/<cert-name>/{fullchain,privkey}.pem. - Add a corresponding
:80block doingreturn 301 https://<domain>$request_uri;(preserve$request_uriso HTTP-01 redirects work). - Cert doesn't exist yet, so step 2's
:443block would crash nginx if deployed now — temporarily comment it out, deploy:80only withpyinfra @local deploy.py, then issue:sudo certbot certonly --cert-name <name> -d <domain> [-d <alias>]...(cli.inisupplies the rest, including the sharedwebroot-path). To add a name to an existing cert, repeat the full-dlist with the new name appended. - Uncomment the
:443block;pyinfra @local deploy.pyagain.
If a :80 or :443 server uses server-level return / proxy_pass / aggressive location ~ regexes that would short-circuit the snippet, restructure the redirect into a location / { return ...; } so the snippet's ^~ prefix match wins.
Renewal happens automatically via certbot.timer (twice daily). The reload-nginx deploy hook reloads nginx after any successful renewal.
Pre-certbot/ certs were issued with the nginx authenticator, which still tries to edit nginx config files at renewal time. Because the ACME snippet is in every :443 server block, the migration is uniform across all certs:
sudo certbot reconfigure --cert-name <cert-name> --webroot
reconfigure performs a staging-style validation against the new config and only commits to /etc/letsencrypt/renewal/<cert-name>.conf if it passes. List current certs with sudo certbot certificates. Migrating opportunistically as you next touch each site is fine — unmigrated certs continue to renew with --nginx until you flip them.
The cyrus-sasl password DB used by smtpd_sasl_auth_enable=yes lives at /var/spool/postfix/etc/sasldb2 (postfix is chrooted, so it sees /etc/sasldb2 from inside its spool). /etc/sasldb2 is a symlink to that chroot path so the cyrus-sasl admin tools (saslpasswd2, sasldblistusers2), which default to /etc/sasldb2, target the same file postfix actually reads.
The DB itself contains live, plaintext-equivalent passwords (because the mech list includes CRAM-MD5/DIGEST-MD5, which require the server to know the cleartext) and is therefore not in the repo. To add or rotate:
sudo saslpasswd2 -c -u $(sudo postconf -h myhostname) <user> # add or update
sudo saslpasswd2 -d -u $(sudo postconf -h myhostname) <user> # delete
sudo sasldblistusers2 # list (no passwords shown)
The realm flag (-u) is just a namespace inside sasldb — it does not have to match myhostname or any DNS name. Clients authenticate as <user>@<realm>, and cyrus-sasl splits on @ to look up the entry. If the client sends a bare username with no @, the default realm is $myhostname (postfix's cyrus-sasl integration), so picking the realm to match myhostname is one sensible convention but not a requirement. In practice the entries on this host are stored at the user's own domain (e.g. egnor@ofb.net, shop@seventeengames.com) so they survive myhostname changes untouched. No postfix reload or pyinfra deploy is needed; sasldb is read per-authentication.
Most zones in dns/files/zones/ are the boilerplate apex SOA + $INCLUDE _common.inc shape: apex A/AAAA point at this host (104.200.25.248, the two IPv6 addresses), and a single-label * CNAME @ wildcard covers everything else. To add another zone in that shape:
-
Create
dns/files/zones/<zone>.zonewith this content (swap the$ORIGIN):; Managed by pyinfra. Source: dns/files/zones/<zone>.zone $ORIGIN <zone>. $TTL 1h @ SOA ns1.eacs.io. hostmaster.eacs.io. ( 1 1h 15m 7d 1h ) $INCLUDE /etc/knot/zones/_common.inc -
Add the zone name to the list in
dns/files/knot.conf. -
pyinfra @local deploy.py. Knot reloads; checkjournalctl -u knot --since "1 minute ago"for parse errors. -
Validate before pointing real traffic at it:
dig @127.0.0.1 <zone> SOA dig @127.0.0.1 something.<zone> A # should resolve via wildcard -
If the zone needs MX, SPF, DMARC, DKIM, or non-wildcard subdomains, put them after the
$INCLUDEline — explicit records override the wildcard. Seeeacs.io.zone(mail-receiving) andteamleftout.org.zone(Google Workspace + custom records) for examples of the deviation patterns.
The SOA serial in the file stays 1 forever. Knot detects content changes via zonefile-load: difference-no-serial and writes a real serial (Unix timestamp) into its journal at /var/lib/knot/.
The * CNAME @ wildcard in _common.inc silently catches _dmarc.<zone> queries on every zone served from here, CNAMEing them to the apex (which carries SPF TXT, not DMARC TXT). DMARC verifiers see a non-DMARC TXT and conclude "no policy" — so all zones served from this host are effectively DMARC-unprotected unless they ship an explicit _dmarc TXT. Adding any record at _dmarc.<zone> suppresses the wildcard for that name (DNS wildcards don't expand for names that have other records).
Reporting is consolidated at Postmark (free DMARC processor); each zone needs its own per-zone identifier registered there. To migrate or add a zone:
-
Log in to Postmark's DMARC dashboard, "Add domain", enter
<zone>. Postmark issues anruatoken likere+xxxxxxxxxxxx@dmarc.postmarkapp.comunique to that domain. -
Add
_dmarcto the zone file after the$INCLUDEline (so the wildcard CNAME for that name is overridden):_dmarc 1h TXT "v=DMARC1; p=quarantine; pct=100; rua=mailto:re+xxxxxxxxxxxx@dmarc.postmarkapp.com; sp=none; aspf=r;"Style notes (matching the existing Postmark-using domains):
p=quarantinefor enforcement,sp=noneto leave subdomain policy permissive,aspf=rfor relaxed SPF alignment (since SRS-rewriting may break strict alignment across forwarders). -
pyinfra @local deploy.pyto reload Knot and propagate to HE secondaries. -
Send a couple of test messages and check Postmark's dashboard the next day — it aggregates reports from receiving MTAs (Gmail/Yahoo/Microsoft) into a single view.
The eacs.io zone currently uses rua=mailto:info@eacs.io (raw reports land in the local mailbox) — migrate it to Postmark like any other zone when convenient.
ofb.net's DMARC (v=DMARC1;p=none; with no rua) lives in ofb's own DNS config (not this repo) — it would be updated via reference/ofb_config_bind/config.sh on the ofb side, not here.
The Knot config in dns/setup.py also stops named.service (BIND9), since the two can't share port 53. Before the cutover, BIND was running here as ns2.ofb.net — secondary for ofb.net's then ~124 zones, slaving from 104.197.242.163. OFB has since been weaned off rely on egnor-2020 for slave service.
First-time procedure on egnor-2020:
-
Dry-run to preview what pyinfra will do:
pyinfra @local deploy.py --dry. Confirm the BIND stop / Knot start ops appear once and only once. -
Validate the config offline before pyinfra reloads Knot for real:
sudo knotc -c dns/files/knot.conf conf-check. ExpectConfiguration is valid. (Validating zone files needskzonecheck, which is not packaged on Ubuntu —named-checkzonefrom BIND9 works as a stand-in since the syntax is compatible.) -
Deploy:
pyinfra @local deploy.py. There's a 1–2 second window between BIND stopping and Knot binding where port 53 is unanswered; ns1.ofb.net continues to serve the slaved zones from its end, so external resolvers see the zones as briefly less-redundant rather than down. -
Smoke test after deploy:
dig @127.0.0.1 eacs.io SOA # should show ns1.eacs.io. (primary) dig @127.0.0.1 ofb.net SOA # should show ofb.net. (slaved from ns1.ofb.net) sudo knotc zone-status # primary + secondary zones all "open" journalctl -u knot -n 50 # look for AXFR completed lines, no errors -
/etc/bind/is left in place but inert (the package isn't removed; the unit is disabled). Clean upapt purge bind9later once you're confident Knot is stable. -
Reboot, or restart long-running daemons that cache resolver state. Switching
/etc/resolv.conffrom systemd-resolved's stub to the non-stub file invalidates the cached resolver address (127.0.0.53) inside any already-running process. glibc doesn't always notice the symlink target change. Most-affected daemons here:postfix(mail deferred with "Name service error … try again"),opendkim(DKIM lookups),netdata(HTTP-check alerts firing for hostnames it can't resolve).systemctl restart postfix opendkim netdatafixes them; a reboot catches everything plus validates cold-boot of the new config.
Once the zone file is live in Knot (previous section), flipping the actual delegation is an external action — not automatable from this repo. Note: ten zones (teamleftout.org, the six puzzlehunt zones, nutrimatic.org, dan.egnor.name, egnor.me) appear in both Knot's primary list and in ofb's master.conf. While the duplicate-mastering condition exists, ns1.ofb.net and ns2.ofb.net answer with different content (different NS sets, no MX, no _http._tcp SRV records on the Knot side per _common.inc). Externally resolvers see a "lame delegation" — usually harmless, but worth closing out promptly via step 2 below.
-
Register the secondary at dns.he.net. Log in to Hurricane Electric DNS, "Add new zone", enter
<zone>. HE will start AXFR-pulling from this host on demand; thehe_xfrACL inknot.confis already open to their 10 IPs. Verify HE has the zone:dig @ns1.he.net <zone> SOA -
Remove the zone from ofb's master. For the ten zones currently on both sides: edit
reference/ofb_config_bind/config.shto drop the correspondingmaster <zone> ...line, re-run the script to regeneratemaster.confandslave.conf,rndc reloadon ofb. After this, ofb's ns1 stops answering for the zone; only Knot here (and HE.net once step 1 is done) are authoritative. -
Update NS records at the registrar. Replace the existing nameservers with:
ns1.eacs.io. ns1.he.net. ns2.he.net. ns3.he.net. ns4.he.net. ns5.he.net.For zones whose registrar is Porkbun or Squarespace (most of ours), this is a single form field per zone.
-
Glue records (eacs.io only). Because
ns1.eacs.iois in theeacs.iozone it serves, the.ioregistrar (Squarespace) needs explicit "host" records:ns1.eacs.io = 104.200.25.248plus the two IPv6 addresses. Without glue, resolvers can't find ns1.eacs.io to ask it for eacs.io. No glue is needed for other zones — they just list ns1.eacs.io and let resolvers chase it through.io.
TTLs at the apex are 1h, so propagation is fast. The old NS set keeps answering until its parent-TLD TTL expires (typically 1–2 days), so a brief overlap where both servers answer correctly is normal and safe.
- Create
<area>/setup.pyand<area>/files/. - Gate the work on whatever fact applies (
Hostname,LinuxName, group membership) so other hosts no-op cleanly. - Reference content with
src="<area>/files/<name>"(resolved from the deploy directory, which is the repo root when runningpyinfra ... deploy.py). - Files that should look identical in repo and on disk get a
# Managed by pyinfra. Source: ...header in the source file itself — no template needed. Usefiles.template(Jinja) only if you need real interpolation. - Wire follow-up actions (reload/restart) with
_if=op.did_change(one op) or_if=any_changed(op1, op2, ...)(multiple). - Add
local.include("<area>/setup.py")todeploy.py.
- OS family:
if host.get_fact(LinuxName) in ("Ubuntu", "Debian"): - Specific host:
if host.get_fact(Hostname) == "egnor-2020": - Group membership:
if "system_admin" in host.groups:
We deliberately don't maintain a central host-config dict; each area's setup.py checks for the hosts it should run on.
user/files-linux/.config/mise/config.toml pins the tools the .zshrc expects to find on PATH (fd, gh, lazygit, neovim, node, python, ripgrep, @github/copilot-language-server). mise is activated from .zshrc if present. If you add a shell helper that depends on a new binary, add it to the mise config in the same change.
The repo-root mise.toml and pyproject.toml/uv.lock set up the Python env that runs pyinfra itself (via uv sync).
Neovim config under user/files-linux-modern/.config/nvim/ is a LazyVim starter — plugins are managed by lazy.nvim at runtime, not vendored here.