# /etc/systemd/system/hsc.service — written by hsc-setup.sh, do not edit here. # # Edit selfhost/templates/hsc.service.tmpl and re-run `hsc-setup.sh install`. # @BUN@ is resolved by the installer from mise, so re-running the installer is # also how you pick up a bun upgrade (`mise up bun` then `hsc-setup.sh install`). # # systemctl reload hsc # re-reads keys.json, without dropping a request # journalctl -u hsc -f # one line per request [Unit] Description=headless-seo-check audit API (loopback only, fronted by nginx) Documentation=https://github.com/bastienallain/headless-seo-check After=network-online.target Wants=network-online.target [Service] Type=exec User=@USER@ Group=@USER@ WorkingDirectory=@DIR@ Environment=HSC_BIN=@DIR@/bin/headless-seo-check Environment=HSC_KEYS=@DIR@/keys.json Environment=HSC_PORT=@PORT@ # The default is the point: nothing but nginx, on this machine, can reach it. Environment=HSC_HOST=127.0.0.1 # Behind Cloudflare this stays under the proxy's ~100 s cut-off, so a slow audit # ends in our own 504 — with a body that explains it — instead of a 524. Environment=HSC_TIMEOUT_MS=@TIMEOUT_MS@ ExecStart=@BUN@ run @DIR@/server.ts # SIGHUP re-reads keys.json; a malformed file keeps the previous map. ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=2 TimeoutStopSec=20 # Hardening. The service writes nothing to disk, ever — neither the binary nor # the server — so it can live in a strictly read-only world. NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadOnlyPaths=@DIR@ PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictNamespaces=true RestrictSUIDSGID=true LockPersonality=true RemoveIPC=true # Outbound HTTP(S) and DNS is the whole job, so the network stays open. RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # THE SSRF BOUNDARY. A key whose keys.json entry is ["*"] audits whatever a # visitor typed, so no allowlist stands between a stranger and this machine's # network. These rules are what actually stops a packet, and they are the only # layer that can: the audit binary follows redirects, so a public URL may answer # 302 to 169.254.169.254 — an address no pre-flight DNS check ever saw — and a # name whose TTL is one second can point somewhere else between our lookup and # the binary's. `public-target.ts` refuses the obvious cases early and turns # this into a readable 400; it does not replace these lines. # # 169.254.0.0/16 is the one that matters most: cloud metadata lives there, and # an SSRF that reaches it is stolen instance credentials, not a nuisance. # # Allow rules win over deny rules in systemd, and these filters apply to both # directions, so loopback must stay allowed for nginx to reach the service at # all. What remains reachable on 127.0.0.1 is nginx (which demands a key) and # this service (whose /healthz says `{"ok":true}`); `public-target.ts` refuses # loopback names and literals. Closing that last gap means moving the nginx hop # to a unix socket — see selfhost/README.md. IPAddressAllow=127.0.0.0/8 ::1/128 IPAddressDeny=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10 192.0.0.0/24 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 fc00::/7 fe80::/10 ff00::/8 # Bun is a JIT: MemoryDenyWriteExecute=true would kill it on the first page it # compiles. Deliberately absent. SystemCallArchitectures=native # If bun ever dies with SIGSYS on a new kernel, this is the line to comment out. SystemCallFilter=@system-service [Install] WantedBy=multi-user.target