aboutsummaryrefslogtreecommitdiff
path: root/CLAUDE.md
blob: 6817d40b0dde2b2296c8328640c39ee26cd73e29 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A flake-based, modular NixOS configuration for a single personal server ("eurovm") running on
Hetzner Cloud in Europe. There is no application code here — this repo *is* the infrastructure:
editing a `.nix` file and deploying is the equivalent of shipping a change.

## Commands

There is no local build/test/lint step in the traditional sense — the workflow is evaluate -> deploy
against the live host. Nix itself must be installed locally with flakes enabled (`nix --version`).

Check the flake evaluates without deploying:
```bash
nix flake check
```

Deploy a change:
```bash
./deploy.sh
```
`deploy.sh` reads `SERVER_IP` from a git-ignored `.env` file (copy `.env.example` to start) —
the server's Tailscale IP — and runs `nixos-rebuild switch --flake .#eurovm` against it with
both `--target-host` and `--build-host` set to that address, so the server always builds its
own closure regardless of the local machine's architecture.

First-time provisioning of a fresh VM (partitions the disk via disko, installs NixOS):
```bash
nix run github:nix-community/nixos-anywhere -- \
  --flake .#eurovm \
  --build-on remote \
  root@<server-ipv4>
```

Edit encrypted secrets (re-encrypts on save):
```bash
sops secrets/secrets.yaml
```

Add a new sops recipient (e.g. after rotating a key or adding a new machine) and re-encrypt:
```bash
sops updatekeys secrets/secrets.yaml
```

Full first-deploy / bootstrap sequence (age key setup, DNS, secrets, provisioning, per-service
first-login steps) is documented step by step in `README.md` — follow it rather than re-deriving
the sequence from the module files.

## Architecture

**Single flake, single host.** `flake.nix` defines exactly one `nixosConfigurations.eurovm`
target, pinned to `system = "x86_64-linux"` (switch to `aarch64-linux"` for an ARM Hetzner
instance). Inputs are `nixpkgs` (pinned to `nixos-26.05`), `disko`, and `sops-nix`.

**Host assembly (`hosts/eurovm/`)** wires together hardware profile, disk layout, and every
service module:
- `default.nix` — imports everything, sets hostname, the `admin` user + its authorized SSH keys,
  passwordless sudo for wheel, `system.stateVersion`.
- `hardware.nix` — Hetzner virtio/qemu-guest profile, GRUB (BIOS boot), network via DHCP on eth0.
- `disko.nix` — declarative disk partitioning consumed by `nixos-anywhere` on first install: a
  1MiB BIOS-boot partition, a 512MiB ext4 `/boot`, and the rest as ext4 `/`.

**Service modules (`modules/`)** are one file per concern, each self-contained and imported by
`hosts/eurovm/default.nix`:
- `common.nix` — SSH hardening (key-only, no root login), firewall (22/80/443/853/8443 only —
  plain DNS on 53 is deliberately closed, see `adguard.nix`), timezone, nix settings (flakes,
  auto-gc, auto-optimise-store), base packages, disabled autoUpgrade.
- `sops.nix` — sops-nix wiring. The host's own age key is derived at boot from
  `/etc/ssh/ssh_host_ed25519_key`, so no separate host key material needs to be provisioned.
  Declares which secrets exist, their owning user/group, and file mode.
- `acme.nix` — an independent `security.acme` certificate for `dns.karanj.com`, used only by
  AdGuard's own DoH/DoT TLS listener (not by Caddy, which gets its HTTPS for the same domain
  through its own separate, built-in ACME client). Issued via HTTP-01 against a webroot that
  `caddy.nix` serves on port 80 for that one vhost. The cert directory is group-owned by
  `adguardhome` (`security.acme.certs.<name>.group`) so the service can read it without running
  as root, and `reloadServices` restarts `adguardhome` on renewal.
- `caddy.nix` — the main thing that binds to 80/443 (see `adguard.nix` below for the one
  exception). Reverse-proxies each subdomain to a service's localhost port; the `dns.karanj.com`
  vhost additionally serves the ACME HTTP-01 webroot for `acme.nix`. Application ports (5006,
  8080, 8086) are bound to `127.0.0.1` and never exposed directly — Caddy is the public HTTP(S)
  entry point for everything except AdGuard's encrypted-DNS ports, and handles Let's Encrypt
  automatically for its own certs.
- `adguard.nix` — AdGuard Home; plain DNS (`dns.port`) is set to `0` and deliberately not opened
  in the firewall — CERT-Bund/BSI flagged this host as an open DNS resolver abusable for UDP
  reflection/amplification DDoS (spoofed source IP, no handshake needed), so plain DNS is
  disabled entirely and DNS-over-TLS (`0.0.0.0:853`) / DNS-over-HTTPS (`0.0.0.0:8443`, path
  `/dns-query`) — both TLS-handshake-based and therefore not spoofable the same way — are the
  only supported client paths. Web UI on `0.0.0.0:3000` (port 3000 is never opened in the
  firewall, so it's still effectively internal-only, reached via Caddy).
  `http.address` binds `0.0.0.0` rather than `127.0.0.1` specifically because AdGuard's
  HTTPS/DoH listener reuses that same bind host internally (confirmed from AdGuard's source) —
  narrowing it back to loopback would silently make DoH unreachable. TLS for DoH/DoT uses the
  cert from `acme.nix`; `systemd.services.adguardhome.after`/`wants` on `acme-dns.karanj.com.service`
  ensures the cert exists before AdGuard's first start.
  Runs with `mutableSettings = false` (fully declarative config), which creates a specific
  problem: the module's own `ExecStartPre` overwrites the state file with a config containing a
  literal `REPLACED_AT_ACTIVATION` placeholder on every start. A second `ExecStartPre`, ordered
  with `lib.mkAfter`, substitutes in the real bcrypt hash from the sops secret immediately before
  `ExecStart`. It also uses a static (non-`DynamicUser`) system user, because sops-nix (and now
  the acme cert's group ownership) need a stable UID/GID to chown to during activation, while a
  `DynamicUser` only exists while its service is actually running. If you touch this module,
  preserve that ordering; it's the whole point of the file.
- `miniflux.nix` — RSS reader; admin account seeded from a sops credentials file (`ADMIN_USERNAME`
  / `ADMIN_PASSWORD` format) on first run. Uses the NixOS module's own PostgreSQL integration.
- `actual.nix` — Actual Budget via the native `services.actual` module (ships in this flake's
  pinned nixpkgs). No secrets involved; the server password is set interactively on first
  browser visit.
- `cgit.nix` — git hosting. Three-way access split: web browsing is public/unauthenticated via
  cgit, `git clone`/`pull` over HTTPS is public/read-only via `git-http-backend` + fcgiwrap, and
  `git push` is SSH-only through a dedicated `git` system user with its own authorized keys. cgit
  browsing and git-http-backend are both served by the *same* localhost-only nginx vhost
  (127.0.0.1:8086) — nginx tells the two apart by matching the request path (a regex for
  `info/refs`/`git-upload-pack`), not by a separate port — and Caddy reverse-proxies everything
  for `git.karanj.com` straight to that one vhost. Only `git-upload-pack` is wired up, so push
  over HTTP is impossible regardless of nginx config; push stays SSH-only structurally, not just
  by convention.

**Secrets (`secrets/secrets.yaml` + `.sops.yaml`)** — encrypted with sops using age keys. Three
recipients: two personal keys (`einstein`, `galileo` — the user's two machines) plus the host's
own key (`eurovm`, derived from its SSH host key), so the file can be decrypted both from a
laptop and by the running server. When adding a new secret, declare it in `modules/sops.nix`
(owner/group/mode/restartUnits) before referencing its `config.sops.secrets."<name>".path` in a
service module.

## Conventions to preserve

- Every module takes only the NixOS module arguments it actually uses (`{ config, pkgs, lib, ... }`
  etc.) — don't pad the argument set.
- New services follow the same shape: bind the app to `127.0.0.1:<port>`, add a `virtualHosts`
  entry in `caddy.nix`, and if the service needs a persistent secret, declare it in
  `modules/sops.nix` first.
- Comments in modules explain *why* a workaround exists (e.g. the adguard password-injection
  dance, the nginx shim in cgit) — keep that context when editing those files rather than
  stripping it.