| 1 | --- |
| 2 | # SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | # |
| 4 | # Base hardening: apt baseline, ufw, fail2ban-lite, system users. |
| 5 | |
| 6 | - name: Apt — install baseline packages |
| 7 | apt: |
| 8 | name: |
| 9 | - curl |
| 10 | - wget |
| 11 | - ca-certificates |
| 12 | - ufw |
| 13 | - fail2ban |
| 14 | - sudo |
| 15 | - git |
| 16 | - jq |
| 17 | - rsync |
| 18 | - rclone # cross-region Spaces sync (S37) |
| 19 | - tzdata |
| 20 | - unattended-upgrades |
| 21 | - python3-psycopg2 # community.postgresql modules need this |
| 22 | state: present |
| 23 | update_cache: yes |
| 24 | |
| 25 | - name: Timezone — pin to UTC for log + cron sanity |
| 26 | community.general.timezone: |
| 27 | name: UTC |
| 28 | |
| 29 | - name: Unattended security upgrades — enable |
| 30 | copy: |
| 31 | dest: /etc/apt/apt.conf.d/20auto-upgrades |
| 32 | content: | |
| 33 | APT::Periodic::Update-Package-Lists "1"; |
| 34 | APT::Periodic::Unattended-Upgrade "1"; |
| 35 | mode: "0644" |
| 36 | |
| 37 | - name: UFW — defaults |
| 38 | ufw: |
| 39 | direction: incoming |
| 40 | policy: deny |
| 41 | - name: UFW — allow ssh |
| 42 | ufw: { rule: allow, port: "22", proto: tcp } |
| 43 | - name: UFW — allow http (Caddy redirect to https) |
| 44 | ufw: { rule: allow, port: "80", proto: tcp } |
| 45 | - name: UFW — allow https |
| 46 | ufw: { rule: allow, port: "443", proto: tcp } |
| 47 | - name: UFW — allow wireguard |
| 48 | ufw: { rule: allow, port: "51820", proto: udp } |
| 49 | - name: UFW — enable |
| 50 | ufw: { state: enabled } |
| 51 | |
| 52 | - name: System users — create shithub (web/worker) |
| 53 | user: |
| 54 | name: "{{ shithub_user }}" |
| 55 | system: yes |
| 56 | home: "/var/lib/shithub" |
| 57 | shell: /usr/sbin/nologin |
| 58 | create_home: yes |
| 59 | |
| 60 | - name: System users — create shithub-ssh (AKC) |
| 61 | user: |
| 62 | name: shithub-ssh |
| 63 | system: yes |
| 64 | home: /var/lib/shithub-ssh |
| 65 | shell: /usr/sbin/nologin |
| 66 | create_home: yes |
| 67 | |
| 68 | # The `git` user is the SSH login target sshd matches against in the |
| 69 | # sshd_config Match-User-git block. Three subtleties learned during |
| 70 | # first-time enable: |
| 71 | # |
| 72 | # 1. Shell can't be nologin (sshd rejects) — git-shell is the right |
| 73 | # choice for defense-in-depth. |
| 74 | # 2. `useradd --system` defaults to a LOCKED password (`!` in |
| 75 | # shadow); sshd refuses any auth (including pubkey) for locked |
| 76 | # accounts. `passwd -d` clears the password to NP (no password) |
| 77 | # which sshd accepts. With `PasswordAuthentication no` globally, |
| 78 | # no-password is fine — pubkey is the only path. |
| 79 | # 3. ssh-shell (the AKC's forced command) needs to read |
| 80 | # /etc/shithub/web.env for SHITHUB_DATABASE_URL. Adding `git` to |
| 81 | # the `shithub` group + chmod g+r on web.env grants exactly that. |
| 82 | - name: Ensure git-shell is installed (provided by `git` package, already a dep) |
| 83 | command: which git-shell |
| 84 | register: git_shell |
| 85 | changed_when: false |
| 86 | |
| 87 | - name: System users — create git (SSH login target) |
| 88 | user: |
| 89 | name: git |
| 90 | system: yes |
| 91 | home: /var/lib/git |
| 92 | shell: "{{ git_shell.stdout }}" |
| 93 | create_home: yes |
| 94 | groups: ["{{ shithub_group }}"] |
| 95 | append: yes |
| 96 | |
| 97 | - name: System users — unlock git (sshd refuses locked accounts even for pubkey) |
| 98 | command: passwd -d git |
| 99 | register: passwd_d |
| 100 | changed_when: passwd_d.stdout is search('password changed') |
| 101 | |
| 102 | - name: Data root — create + own |
| 103 | file: |
| 104 | path: "{{ shithub_data_root }}" |
| 105 | state: directory |
| 106 | owner: "{{ shithub_user }}" |
| 107 | group: "{{ shithub_group }}" |
| 108 | mode: "0755" |
| 109 | |
| 110 | # Caddy access log carries the real client IP (Caddy terminates TLS |
| 111 | # and shithubd only ever sees 127.0.0.1 over the loopback proxy). |
| 112 | # This filter scans Caddy's JSON-formatted access log for failed |
| 113 | # auth attempts. Field order in Caddy's JSON output is stable in |
| 114 | # practice; the regex is deliberately substring-anchored so the |
| 115 | # `<HOST>` group always lands on the request.remote_ip value. |
| 116 | - name: fail2ban — shithubd auth filter (Caddy access log) |
| 117 | copy: |
| 118 | dest: /etc/fail2ban/filter.d/shithubd-auth.conf |
| 119 | content: | |
| 120 | [Definition] |
| 121 | failregex = ^.*"remote_ip":"<HOST>".*?"method":"POST".*?"uri":"/(login|signup|password/reset|password/forgot|2fa(/[^"]*)?|settings/security/2fa/[^"]*)".*?"status":(401|403|429)\b |
| 122 | ignoreregex = |
| 123 | mode: "0644" |
| 124 | notify: restart fail2ban |
| 125 | |
| 126 | - name: fail2ban — jail config (sshd + shithubd-auth) |
| 127 | copy: |
| 128 | dest: /etc/fail2ban/jail.d/shithub.local |
| 129 | content: | |
| 130 | [sshd] |
| 131 | enabled = true |
| 132 | maxretry = 5 |
| 133 | findtime = 600 |
| 134 | bantime = 3600 |
| 135 | |
| 136 | # Bans clients that hammer the auth surface past the app-level |
| 137 | # throttle. The per-user throttle in internal/auth/throttle stops |
| 138 | # password guessing; this jail stops the network noise BEFORE |
| 139 | # the request reaches the proxy, freeing connection slots. |
| 140 | # Slightly more lenient than sshd because legitimate users may |
| 141 | # legitimately fail the 2FA challenge once or twice. |
| 142 | [shithubd-auth] |
| 143 | enabled = true |
| 144 | filter = shithubd-auth |
| 145 | logpath = /var/log/caddy/access.log |
| 146 | maxretry = 8 |
| 147 | findtime = 600 |
| 148 | bantime = 3600 |
| 149 | backend = auto |
| 150 | mode: "0644" |
| 151 | notify: restart fail2ban |
| 152 | |
| 153 | # AIDE file-integrity monitoring lives in its own task file so the |
| 154 | # nightly-check + baseline-init noise doesn't clutter the main flow. |
| 155 | - name: AIDE file-integrity monitoring |
| 156 | import_tasks: aide.yml |
| 157 |