spinney / docs

How the server is protected, layer by layer

How spinney is protected

spinney is built for one owner and many anonymous readers. The owner pushes over SSH; everyone else may only read, over HTTPS. Security comes from keeping those two paths apart and giving each as little power as possible.

anyone --HTTPS--> Caddy --> Anubis --> spinney --read-----> /srv/git
                  :443      127.0.0.1  127.0.0.1               ^
owner --SSH (Tailscale only)--> sshd --> git account --write---+
owner --SSH (Tailscale only)--> sshd --> admin account --sudo--> root

What is protected, and from whom

What Against Where the line is
The site staying up Anyone on the internet, scrapers Caddy → Anubis → spinney's own limits
The repositories' contents A stolen push key; anyone trying to write over HTTP The git account; HTTP is read-only
Root on the server Code running as spinney or git; anyone reaching SSH Service accounts, systemd sandboxing, SSH settings
SSH itself Password guessing, scanning Firewall, Tailscale, key-only logins

The network: what can be reached at all

With --harden, the firewall (ufw on Debian and Ubuntu, firewalld on Fedora) drops everything incoming except:

spinney (8080) and Anubis (8923, metrics on 8924) listen on 127.0.0.1 only, so they can't be reached from outside even without a firewall.

Tailscale is WireGuard underneath: only devices you have added to your tailnet can open a connection, and your tailnet's access rules can narrow that further (for example, only your laptop may reach port 22). Behind the firewall there is a second, independent lock in sshd:

Match Address *,!100.64.0.0/10,!fd7a:115c:a1e0::/48,!127.0.0.0/8,!::1
    DenyUsers *

Any login from an address outside Tailscale's ranges (and localhost) is refused, so SSH stays closed even if the firewall is turned off. ListenAddress is deliberately not used: if sshd started before Tailscale at boot, it would fail to start and lock everyone out.

A cloud provider's firewall can add a third layer: allow only 80 and 443 there too.

The web side: read-only by design

spinney has no write path. It never runs git push, receive-pack or any command that changes a repository, and it has no accounts or sessions.

Resource limits

Anubis doesn't check git clients, so spinney limits work on its own (internal/server/server.go):

Limit Value
Concurrent page requests max(8, 4 × CPUs); a request waits at most 10 s for a slot, then gets 503
Concurrent downloads (clones, tarballs) max(4, CPUs), a separate pool with the same 10 s wait, so downloads can never starve page views
Time per page 30 s
Time per download 10 min
A download that stops moving dropped after 20 s without progress
Upload body of a clone negotiation 64 MB
Request headers 10 s to arrive
File shown with highlighting 2 MB; larger files are offered raw

The push side: the git account

Pushes arrive over SSH as the git account, which is built to do one thing:

Admin access

Not locking yourself out

Hardening SSH on a remote server is the easiest way to lose it, so every step checks before it commits:

Secrets

Secret How it is handled
Tailscale auth key Typed hidden (or from a file or TS_AUTHKEY). Uploaded in a mode-600 file for that one run, passed as --auth-key=file:… so it never appears in the process list, deleted afterwards. Never saved in config/deploy.env or install-command.sh.
Admin password Typed hidden, twice. Hashed on your machine (openssl passwd -6); only the hash is uploaded (mode 600, deleted after the run) and set with chpasswd -e, never on a command line.
Anubis signing key Generated on the server, kept in /etc/anubis/spinney.env (mode 600) across re-runs. A dry run prints a placeholder instead.
Your SSH private keys Never leave your machine. Only public keys are uploaded.

--dry-run never reads any of these.

The installer

The installer runs as root and writes into a folder the git account can write to, so it is careful about how it writes:

Sandboxed services

spinney and Anubis run as their own system users under systemd sandboxing (and Caddy too with --harden, while its Caddyfile is the installer's own): read-only system and home folders, no new privileges, private /tmp and devices, a system-call filter (@system-service), no writable and executable memory, no kernel tuning, modules or logs, invisible other processes, UMask=0077, and no capabilities except binding ports 80 and 443 for Caddy. systemd-analyze security rates all three "OK" (about 1.5–1.7).

With --harden, Caddy's admin API moves from localhost:2019, where any local process could reconfigure Caddy, to a unix socket only root and Caddy can open.

Kernel settings

--harden also writes /etc/sysctl.d/99-zz-spinney-hardening.conf (named to come after the distribution's own files, which it overrides) and /etc/modprobe.d/spinney-hardening.conf, from Lynis's suggestions:

Left alone on purpose: rp_filter (Tailscale needs loose mode), net.ipv4.ip_forward (Tailscale exit nodes and subnet routers set it), kernel.modules_disabled (it would stop WireGuard loading after boot), and two that a service sets again at every boot, log_martians (ufw) and fs.suid_dumpable (Ubuntu's crash reporter, apport). The installer reads each value back and says if something else set it differently.

Updates and software sources

How this is tested

Not yet tested: SELinux in enforcing mode, firewalld's Tailscale zone on Fedora, and the newer features (above) on the other distributions.

Known limits