|
| 1 | +{ |
| 2 | + config, |
| 3 | + pkgs, |
| 4 | + modulesPath, |
| 5 | + ... |
| 6 | +}: |
| 7 | +{ |
| 8 | + imports = [ "${modulesPath}/virtualisation/qemu-vm.nix" ]; |
| 9 | + |
| 10 | + networking.hostName = "scranton"; |
| 11 | + networking.firewall.enable = false; |
| 12 | + time.timeZone = "America/New_York"; |
| 13 | + |
| 14 | + services.openssh.enable = true; |
| 15 | + services.openssh.settings.PermitRootLogin = "yes"; |
| 16 | + users.users.root.password = "hackme"; |
| 17 | + services.getty.autologinUser = "root"; |
| 18 | + |
| 19 | + environment.systemPackages = with pkgs; [ |
| 20 | + curl |
| 21 | + jq |
| 22 | + ]; |
| 23 | + |
| 24 | + # demo-only: stand-ins for the host-file secrets that <attr>File options |
| 25 | + # reach for via systemd LoadCredential. real deployments source these |
| 26 | + # from sops-nix / agenix; never the world-readable nix store. |
| 27 | + environment.etc = { |
| 28 | + "secrets/keycloak-db-password".text = "hackme"; |
| 29 | + "secrets/keycloak-admin-password".text = "hackme"; |
| 30 | + "secrets/jhalpert-password".text = "hackme"; |
| 31 | + "secrets/dunder-mifflin-app-client-secret".text = "topsecret"; |
| 32 | + "secrets/dschrute-password".text = "hackme"; |
| 33 | + "secrets/jhalpert-forgejo-password".text = "hackme"; |
| 34 | + }; |
| 35 | + |
| 36 | + virtualisation = { |
| 37 | + memorySize = 4096; |
| 38 | + diskSize = 8192; |
| 39 | + graphics = false; |
| 40 | + forwardPorts = [ |
| 41 | + { |
| 42 | + from = "host"; |
| 43 | + host.port = 2222; |
| 44 | + guest.port = 22; |
| 45 | + } |
| 46 | + { |
| 47 | + from = "host"; |
| 48 | + host.port = 8080; |
| 49 | + guest.port = 8080; |
| 50 | + } |
| 51 | + { |
| 52 | + from = "host"; |
| 53 | + host.port = 3000; |
| 54 | + guest.port = 3000; |
| 55 | + } |
| 56 | + { |
| 57 | + from = "host"; |
| 58 | + host.port = 8888; |
| 59 | + guest.port = 8888; |
| 60 | + } |
| 61 | + ]; |
| 62 | + }; |
| 63 | + |
| 64 | + # static avatar host. svg rasterised to png at build time so forgejo's |
| 65 | + # Go image decoder (no svg support) can ingest it on SSO. virtualHost |
| 66 | + # name doubles as server_name; "localhost" matches the Host header from |
| 67 | + # both the host browser and forgejo's avatar fetcher inside the vm. |
| 68 | + services.nginx = { |
| 69 | + enable = true; |
| 70 | + virtualHosts.localhost = { |
| 71 | + default = true; |
| 72 | + listen = [ |
| 73 | + { |
| 74 | + addr = "0.0.0.0"; |
| 75 | + port = 8888; |
| 76 | + } |
| 77 | + ]; |
| 78 | + root = pkgs.runCommand "avatars" { nativeBuildInputs = [ pkgs.librsvg ]; } '' |
| 79 | + mkdir -p $out |
| 80 | + rsvg-convert -w 200 -h 200 -o $out/jhalpert.png ${./avatars/jhalpert.svg} |
| 81 | + ''; |
| 82 | + }; |
| 83 | + }; |
| 84 | + |
| 85 | + services.keycloak = { |
| 86 | + enable = true; |
| 87 | + initialAdminPassword = "hackme"; |
| 88 | + settings = { |
| 89 | + hostname = "localhost"; |
| 90 | + http-port = 8080; |
| 91 | + http-enabled = true; |
| 92 | + hostname-strict = false; |
| 93 | + }; |
| 94 | + database.passwordFile = "/etc/secrets/keycloak-db-password"; |
| 95 | + |
| 96 | + themes.dunder_mifflin = pkgs.runCommand "keycloak-theme-dunder-mifflin" { } '' |
| 97 | + cp -r ${./themes/dunder_mifflin} $out |
| 98 | + ''; |
| 99 | + |
| 100 | + runtime = { |
| 101 | + enable = true; |
| 102 | + bootstrapAdminPasswordFile = "/etc/secrets/keycloak-admin-password"; |
| 103 | + |
| 104 | + realms.dunder_mifflin = { |
| 105 | + display_name = "Dunder Mifflin Paper Company"; |
| 106 | + login_theme = "dunder_mifflin"; |
| 107 | + }; |
| 108 | + |
| 109 | + # declare picture (and the four standard attrs) and flip unmanaged |
| 110 | + # to ENABLED so keycloak v24+'s declarative profile stores |
| 111 | + # users.jhalpert.attributes.picture instead of silently dropping it. |
| 112 | + realm_user_profiles.dunder_mifflin = |
| 113 | + let |
| 114 | + stdPerms = { |
| 115 | + view = [ |
| 116 | + "admin" |
| 117 | + "user" |
| 118 | + ]; |
| 119 | + edit = [ |
| 120 | + "admin" |
| 121 | + "user" |
| 122 | + ]; |
| 123 | + }; |
| 124 | + stdAttr = name: { |
| 125 | + inherit name; |
| 126 | + permissions = stdPerms; |
| 127 | + }; |
| 128 | + in |
| 129 | + { |
| 130 | + realm = "dunder_mifflin"; |
| 131 | + unmanaged_attribute_policy = "ENABLED"; |
| 132 | + attribute = [ |
| 133 | + (stdAttr "username") |
| 134 | + (stdAttr "email") |
| 135 | + (stdAttr "firstName") |
| 136 | + (stdAttr "lastName") |
| 137 | + { |
| 138 | + name = "picture"; |
| 139 | + display_name = "Avatar URL"; |
| 140 | + permissions = stdPerms; |
| 141 | + } |
| 142 | + ]; |
| 143 | + }; |
| 144 | + |
| 145 | + users.jhalpert = { |
| 146 | + realm = "dunder_mifflin"; |
| 147 | + username = "jhalpert"; |
| 148 | + email = "jim@dundermifflin.com"; |
| 149 | + first_name = "Jim"; |
| 150 | + last_name = "Halpert"; |
| 151 | + enabled = true; |
| 152 | + email_verified = true; |
| 153 | + # default `profile` client scope maps this into the OIDC `picture` |
| 154 | + # claim; forgejo pulls it on SSO when UPDATE_AVATAR=true. |
| 155 | + attributes.picture = "http://localhost:8888/jhalpert.png"; |
| 156 | + initial_password = { |
| 157 | + valueFile = "/etc/secrets/jhalpert-password"; |
| 158 | + temporary = false; |
| 159 | + }; |
| 160 | + }; |
| 161 | + |
| 162 | + openid_clients.dunder_mifflin_infinity = { |
| 163 | + realm = "dunder_mifflin"; |
| 164 | + client_id = "dunder-mifflin-infinity"; |
| 165 | + name = "Dunder Mifflin Infinity"; |
| 166 | + access_type = "CONFIDENTIAL"; |
| 167 | + client_secretFile = "/etc/secrets/dunder-mifflin-app-client-secret"; |
| 168 | + valid_redirect_uris = [ "http://localhost:3000/user/oauth2/DunderMifflinInfinity/callback" ]; |
| 169 | + web_origins = [ "http://localhost:3000" ]; |
| 170 | + standard_flow_enabled = true; |
| 171 | + }; |
| 172 | + }; |
| 173 | + }; |
| 174 | + |
| 175 | + services.forgejo = { |
| 176 | + enable = true; |
| 177 | + settings.server = { |
| 178 | + HTTP_PORT = 3000; |
| 179 | + DOMAIN = "localhost"; |
| 180 | + ROOT_URL = "http://localhost:3000/"; |
| 181 | + }; |
| 182 | + settings.security.MIN_PASSWORD_LENGTH = 6; |
| 183 | + # forgejo's HTTP client blocks RFC1918 + loopback by default (anti-SSRF); |
| 184 | + # avatar pulls go through that same client, so the local nginx is |
| 185 | + # unreachable without this flip. |
| 186 | + settings.migrations.ALLOW_LOCALNETWORKS = true; |
| 187 | + settings.oauth2_client = { |
| 188 | + ENABLE_AUTO_REGISTRATION = true; |
| 189 | + USERNAME = "preferred_username"; |
| 190 | + ACCOUNT_LINKING = "auto"; |
| 191 | + UPDATE_AVATAR = true; |
| 192 | + }; |
| 193 | + |
| 194 | + runtime = { |
| 195 | + enable = true; |
| 196 | + |
| 197 | + organizations.dunder_mifflin = { |
| 198 | + visibility = "public"; |
| 199 | + description = "Dunder Mifflin Paper Company, Inc."; |
| 200 | + }; |
| 201 | + |
| 202 | + repositories.scranton_branch = { |
| 203 | + owner = "dunder_mifflin"; |
| 204 | + description = "The best branch in the company"; |
| 205 | + private = false; |
| 206 | + }; |
| 207 | + |
| 208 | + # internal repo: only collaborators can see it. forgejo's SSO |
| 209 | + # auto-registration creates a user with login = preferred_username |
| 210 | + # (`jhalpert`); pre-creating jhalpert here lets us name him as a |
| 211 | + # collaborator. ACCOUNT_LINKING=auto matches by email on first SSO, |
| 212 | + # so the pre-created + SSO accounts are the same forgejo user. |
| 213 | + repositories.intranet = { |
| 214 | + owner = "dunder_mifflin"; |
| 215 | + description = "Internal Dunder Mifflin intranet -- not for the warehouse"; |
| 216 | + private = true; |
| 217 | + }; |
| 218 | + |
| 219 | + users.jhalpert = { |
| 220 | + email = "jim@dundermifflin.com"; |
| 221 | + full_name = "Jim Halpert"; |
| 222 | + passwordFile = "/etc/secrets/jhalpert-forgejo-password"; |
| 223 | + must_change_password = false; |
| 224 | + }; |
| 225 | + |
| 226 | + users.dschrute = { |
| 227 | + email = "dschrute@dundermifflin.com"; |
| 228 | + passwordFile = "/etc/secrets/dschrute-password"; |
| 229 | + must_change_password = false; |
| 230 | + }; |
| 231 | + |
| 232 | + collaborators.jhalpert_intranet = { |
| 233 | + repository = "intranet"; |
| 234 | + user = "jhalpert"; |
| 235 | + permission = "write"; |
| 236 | + }; |
| 237 | + }; |
| 238 | + }; |
| 239 | + |
| 240 | + # SSO glue: the svalabs/forgejo terraform provider doesn't model auth |
| 241 | + # sources, so this oneshot calls `forgejo admin auth add-oauth` after |
| 242 | + # both reconcilers are done. The source name doubles as the URL slug |
| 243 | + # in the OAuth2 callback path (must match keycloak's valid_redirect_uris). |
| 244 | + systemd.services.forgejo-oauth-setup = |
| 245 | + let |
| 246 | + fcfg = config.services.forgejo; |
| 247 | + appIni = "${fcfg.customDir}/conf/app.ini"; |
| 248 | + in |
| 249 | + { |
| 250 | + description = "Register Keycloak as a forgejo OAuth2 login source"; |
| 251 | + after = [ |
| 252 | + "forgejo.service" |
| 253 | + "declarative-keycloak.service" |
| 254 | + ]; |
| 255 | + requires = [ |
| 256 | + "forgejo.service" |
| 257 | + "declarative-keycloak.service" |
| 258 | + ]; |
| 259 | + wantedBy = [ "multi-user.target" ]; |
| 260 | + path = [ |
| 261 | + fcfg.package |
| 262 | + pkgs.gawk |
| 263 | + pkgs.curl |
| 264 | + ]; |
| 265 | + environment = { |
| 266 | + GITEA_WORK_DIR = fcfg.stateDir; |
| 267 | + GITEA_CUSTOM = fcfg.customDir; |
| 268 | + }; |
| 269 | + serviceConfig = { |
| 270 | + Type = "oneshot"; |
| 271 | + RemainAfterExit = true; |
| 272 | + User = fcfg.user; |
| 273 | + Group = fcfg.group; |
| 274 | + LoadCredential = [ "client-secret:/etc/secrets/dunder-mifflin-app-client-secret" ]; |
| 275 | + }; |
| 276 | + script = '' |
| 277 | + set -euo pipefail |
| 278 | + sso_name=DunderMifflinInfinity |
| 279 | + if forgejo --config '${appIni}' admin auth list | awk 'NR>1 {print $2}' | grep -qx "$sso_name"; then |
| 280 | + exit 0 |
| 281 | + fi |
| 282 | + for _ in $(seq 1 60); do |
| 283 | + if curl -fsS -o /dev/null \ |
| 284 | + http://localhost:8080/realms/dunder_mifflin/.well-known/openid-configuration; then |
| 285 | + break |
| 286 | + fi |
| 287 | + sleep 2 |
| 288 | + done |
| 289 | + secret="$(cat "$CREDENTIALS_DIRECTORY/client-secret")" |
| 290 | + forgejo --config '${appIni}' admin auth add-oauth \ |
| 291 | + --name "$sso_name" \ |
| 292 | + --provider openidConnect \ |
| 293 | + --key dunder-mifflin-infinity \ |
| 294 | + --secret "$secret" \ |
| 295 | + --scopes "openid email profile" \ |
| 296 | + --auto-discover-url http://localhost:8080/realms/dunder_mifflin/.well-known/openid-configuration |
| 297 | + ''; |
| 298 | + }; |
| 299 | + |
| 300 | + system.stateVersion = "25.05"; |
| 301 | +} |
0 commit comments