--- # gitea_spnego_keytab.yml — Provision the Gitea HTTP SPNEGO keytab from Samba AD # and deploy it as a Kubernetes secret in the gitea namespace. # # What this playbook does: # 1. Creates (if absent) the gitea-http service account in Samba AD. # 2. Registers the SPN HTTP/git.prole.org on that account. # 3. Pins msDS-SupportedEncryptionTypes to AES128+AES256 (value 24, no RC4). # 4. Resets the account password to derive fresh AES keying material. # 5. Exports the domain keytab, rekeys it to a per-SPN keytab, and verifies. # 6. Fetches the keytab to the control node. # 7. Applies it as the Kubernetes secret gitea-krb5-keytab in the gitea namespace. # 8. Restarts and waits for the gitea-spnego-proxy deployment. # 9. Smoke-tests that git.prole.org returns an HTTP 401 (SPNEGO challenge). # # PREREQUISITES # ------------- # 1. myrddin.prole.org is reachable and is the Samba AD DC for PROLE.ORG. # 2. python3 is available on myrddin (used for keytab rekey step). # 3. kubectl on the control node is configured with context prole-service-cluster # and has permission to create/update secrets in namespace gitea. # 4. The gitea-spnego-proxy deployment exists in the gitea namespace. # # FULL RUN (first time or after AD account changes): # ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml # # Force keytab rotation (e.g. after password policy change): # ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml \ # -e force_keytab_reset=true # # Dry-run: # ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml --check # =========================================================================== # Play 1 — Samba AD setup: create account, register SPN, export keytab # =========================================================================== - name: Provision gitea-http SPNEGO account and keytab in Samba AD hosts: ad_dc gather_facts: false become: true vars: gitea_spnego_account: gitea-http gitea_spnego_spn: HTTP/git.prole.org gitea_spnego_realm: PROLE.ORG gitea_spnego_namespace: gitea gitea_kube_context: prole-service-cluster force_keytab_reset: false gitea_spnego_keytab_local: /tmp/git-http-fetched.keytab tasks: # ------------------------------------------------------------------ # 0. Ensure required Debian packages are present # (ldb-tools provides ldbmodify; used to set msDS-SupportedEncryptionTypes) # ------------------------------------------------------------------ - name: Ensure ldb-tools is installed (provides ldbmodify) ansible.builtin.apt: name: ldb-tools state: present update_cache: true cache_valid_time: 3600 tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 1. Idempotency probe — does the gitea-http service account exist? # ------------------------------------------------------------------ - name: Check whether {{ gitea_spnego_account }} user already exists in Samba ansible.builtin.command: cmd: samba-tool user list register: _samba_users changed_when: false failed_when: false check_mode: false tags: [spnego, keytab, gitea] - name: Set fact — gitea-http account already present ansible.builtin.set_fact: gitea_account_is_new: "{{ gitea_spnego_account not in _samba_users.stdout }}" tags: [spnego, keytab, gitea] - name: Report account pre-existence ansible.builtin.debug: msg: >- {{ gitea_spnego_account }} {{ 'is missing — will create.' if gitea_account_is_new else 'already present in Samba.' }} tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 2. Create gitea-http if missing # ------------------------------------------------------------------ - name: Create {{ gitea_spnego_account }} service account if missing ansible.builtin.command: argv: - samba-tool - user - create - "{{ gitea_spnego_account }}" - "--random-password" - "--description=Gitea HTTP SPNEGO service account" register: _gitea_create changed_when: _gitea_create.rc == 0 failed_when: _gitea_create.rc != 0 when: gitea_account_is_new | bool tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 3. Ensure SPN HTTP/git.prole.org is registered on the account # # samba-tool spn add exits 0 whether or not the SPN was already # present but emits "already has SPN" on stdout when it's a no-op. # We mark changed_when=false and suppress the "already exists" # non-error so reruns stay clean. # ------------------------------------------------------------------ - name: Ensure SPN {{ gitea_spnego_spn }} is registered on {{ gitea_spnego_account }} ansible.builtin.command: argv: - samba-tool - spn - add - "{{ gitea_spnego_spn }}" - "{{ gitea_spnego_account }}" register: _gitea_spn changed_when: false failed_when: - _gitea_spn.rc != 0 - "'already' not in (_gitea_spn.stderr | default('') | lower)" tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 4. Locate ldbmodify binary # (same pattern as kerberos_trust_setup.yml) # ------------------------------------------------------------------ - name: Locate ldbmodify binary ansible.builtin.shell: cmd: | for p in /usr/bin/ldbmodify /usr/sbin/ldbmodify /usr/local/bin/ldbmodify /opt/samba/bin/ldbmodify; do [ -x "$p" ] && { echo "$p"; exit 0; } done # Fallback: filesystem scan limited to common roots found=$(find /usr /opt -maxdepth 4 -type f -name ldbmodify 2>/dev/null | head -1) [ -n "$found" ] && { echo "$found"; exit 0; } echo "ldbmodify not found" >&2 exit 1 register: _ldbmodify_path changed_when: false check_mode: false tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 5. Set msDS-SupportedEncryptionTypes=24 (AES128+AES256, no RC4) # # Value 24 = 0x18 = bit3 (AES128-CTS-HMAC-SHA1-96) | # bit4 (AES256-CTS-HMAC-SHA1-96). # Explicitly excludes RC4 (bit2=4) so the Samba KDC will only # issue AES-keyed service tickets for this principal. # Unlike the cross-realm krbtgt (which must use RC4 for MIT # interop), HTTP SPNEGO service tickets are local to PROLE.ORG # and can safely use AES end-to-end. # ------------------------------------------------------------------ - name: Set msDS-SupportedEncryptionTypes=24 (AES128+AES256) on {{ gitea_spnego_account }} ansible.builtin.shell: cmd: | set -euo pipefail ldif=$(mktemp) cat > "$ldif" <<'EOF' dn: CN=gitea-http,CN=Users,DC=prole,DC=org changetype: modify replace: msDS-SupportedEncryptionTypes msDS-SupportedEncryptionTypes: 24 EOF "{{ _ldbmodify_path.stdout | trim }}" -H /var/lib/samba/private/sam.ldb "$ldif" rm -f "$ldif" register: _gitea_enctype changed_when: "'Modified 1 records' in (_gitea_enctype.stdout | default(''))" failed_when: _gitea_enctype.rc != 0 tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 6. Generate a strong random password for AES key derivation, # then reset the account password. # # Gated on: account is new OR caller passed -e force_keytab_reset=true. # The password is ephemeral — we only need it to derive AES keys # in the keytab. Samba generates the keys at setpassword time. # no_log protects the plaintext password from appearing in output. # ------------------------------------------------------------------ - name: Generate ephemeral password for AES key derivation ansible.builtin.set_fact: _gitea_spnego_password: "{{ lookup('password', '/dev/null length=40 chars=ascii_letters,digits') }}" no_log: true when: gitea_account_is_new | bool or force_keytab_reset | bool tags: [spnego, keytab, gitea] - name: Reset {{ gitea_spnego_account }} password to derive fresh AES keys ansible.builtin.command: argv: - samba-tool - user - setpassword - "{{ gitea_spnego_account }}" - "--newpassword={{ _gitea_spnego_password }}" no_log: true register: _gitea_setpw changed_when: _gitea_setpw.rc == 0 failed_when: _gitea_setpw.rc != 0 when: gitea_account_is_new | bool or force_keytab_reset | bool tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 7. Export the full domain keytab to /tmp/full-domain.keytab # # We export all accounts (no --principal filter) and then rekey # to a per-SPN keytab in step 8. This avoids having to construct # the exact principal name expected by samba-tool's filter flag. # ------------------------------------------------------------------ - name: Export full domain keytab to /tmp/full-domain.keytab ansible.builtin.command: argv: - samba-tool - domain - exportkeytab - /tmp/full-domain.keytab register: _gitea_export changed_when: _gitea_export.rc == 0 failed_when: _gitea_export.rc != 0 tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 8. Rekey full-domain.keytab → git-http.keytab # # The inline Python script: # - Parses the MIT keytab format (magic 0x0502, big-endian). # - Finds all entries for gitea-http@PROLE.ORG with etype 17 # (AES128) or 18 (AES256) at the highest KVNO. # - Rewrites those entries with the principal name changed to # HTTP/git.prole.org@PROLE.ORG (name_type=3, KRB_NT_SRV_HST). # - Writes the result to /tmp/git-http.keytab. # # This is the format required by Apache mod_auth_gssapi, nginx # SPNEGO modules, and the gitea-spnego-proxy sidecar. # ------------------------------------------------------------------ - name: Rekey domain keytab to SPN-named keytab /tmp/git-http.keytab ansible.builtin.command: argv: - python3 - -c - | import struct, sys ETYPE_AES128, ETYPE_AES256 = 17, 18 TARGET_PRINCIPAL_COMPONENTS = [b"gitea-http"] TARGET_REALM = b"PROLE.ORG" OUTPUT_COMPONENTS = [b"HTTP", b"git.prole.org"] OUTPUT_NAME_TYPE = 3 # KRB_NT_SRV_HST OUTPUT_REALM = b"PROLE.ORG" INPUT_PATH = "/tmp/full-domain.keytab" OUTPUT_PATH = "/tmp/git-http.keytab" def read_u8(data, pos): return data[pos], pos + 1 def read_u16be(data, pos): return struct.unpack_from(">H", data, pos)[0], pos + 2 def read_u32be(data, pos): return struct.unpack_from(">I", data, pos)[0], pos + 4 def read_counted(data, pos): length, pos = read_u16be(data, pos) value = data[pos:pos + length] return value, pos + length def read_entry(data, pos): """Read one keytab entry, return (entry_dict, next_pos).""" size, pos = read_u32be(data, pos) if size == 0: return None, pos entry_start = pos entry_end = pos + size num_components, pos = read_u16be(data, pos) realm, pos = read_counted(data, pos) components = [] for _ in range(num_components): comp, pos = read_counted(data, pos) components.append(comp) name_type, pos = read_u32be(data, pos) timestamp, pos = read_u32be(data, pos) kvno8, pos = read_u8(data, pos) etype, pos = read_u16be(data, pos) key, pos = read_counted(data, pos) kvno32 = kvno8 if pos < entry_end: kvno32, pos = read_u32be(data, pos) return { "realm": realm, "components": components, "name_type": name_type, "timestamp": timestamp, "kvno": kvno32, "etype": etype, "key": key, }, entry_end def pack_entry(entry): """Serialise one keytab entry back to bytes (including 4-byte length prefix).""" buf = bytearray() def w16(v): buf.extend(struct.pack(">H", v)) def w32(v): buf.extend(struct.pack(">I", v)) def w8(v): buf.append(v) def wc(b): w16(len(b)); buf.extend(b) w16(len(entry["components"])) wc(entry["realm"]) for c in entry["components"]: wc(c) w32(entry["name_type"]) w32(entry["timestamp"]) w8(entry["kvno"] & 0xFF) w16(entry["etype"]) wc(entry["key"]) w32(entry["kvno"]) # 32-bit KVNO extension return struct.pack(">I", len(buf)) + bytes(buf) # --- Parse input keytab --- with open(INPUT_PATH, "rb") as fh: raw = fh.read() magic = struct.unpack_from(">H", raw, 0)[0] if magic != 0x0502: sys.exit(f"Unexpected keytab magic: {magic:#06x} (expected 0x0502)") pos = 2 entries = [] while pos < len(raw): if pos + 4 > len(raw): break entry, pos = read_entry(raw, pos) if entry is None: break entries.append(entry) # --- Select target entries --- def is_target(e): return ( e["realm"] == TARGET_REALM and e["components"] == TARGET_PRINCIPAL_COMPONENTS and e["etype"] in (ETYPE_AES128, ETYPE_AES256) ) target_entries = [e for e in entries if is_target(e)] if not target_entries: sys.exit( f"No AES entries found for " f"{'/'.join(c.decode() for c in TARGET_PRINCIPAL_COMPONENTS)}" f"@{TARGET_REALM.decode()} in {INPUT_PATH}" ) max_kvno = max(e["kvno"] for e in target_entries) selected = [e for e in target_entries if e["kvno"] == max_kvno] print(f"Selected {len(selected)} entries for KVNO={max_kvno}, " f"etypes={[e['etype'] for e in selected]}") # --- Rewrite principal name --- out_entries = [] for e in selected: renamed = dict(e) renamed["components"] = OUTPUT_COMPONENTS renamed["name_type"] = OUTPUT_NAME_TYPE renamed["realm"] = OUTPUT_REALM out_entries.append(renamed) # --- Write output keytab --- with open(OUTPUT_PATH, "wb") as fh: fh.write(struct.pack(">H", 0x0502)) for e in out_entries: fh.write(pack_entry(e)) print(f"Wrote {len(out_entries)} entries to {OUTPUT_PATH}") register: _gitea_rekey changed_when: _gitea_rekey.rc == 0 failed_when: _gitea_rekey.rc != 0 tags: [spnego, keytab, gitea] - name: Print keytab rekey output ansible.builtin.debug: msg: "{{ _gitea_rekey.stdout_lines }}" tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 9. Verify the output keytab contains the expected SPN principal # ------------------------------------------------------------------ - name: Verify /tmp/git-http.keytab contains HTTP/git.prole.org@PROLE.ORG ansible.builtin.command: argv: - klist - -k - -e - /tmp/git-http.keytab register: _gitea_klist changed_when: false failed_when: >- _gitea_klist.rc != 0 or 'HTTP/git.prole.org@PROLE.ORG' not in _gitea_klist.stdout tags: [spnego, keytab, gitea] - name: Print klist output for verification ansible.builtin.debug: msg: "{{ _gitea_klist.stdout_lines }}" tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 10. Fetch keytab to control node # ------------------------------------------------------------------ - name: Fetch git-http.keytab from remote to control node ansible.builtin.fetch: src: /tmp/git-http.keytab dest: "{{ gitea_spnego_keytab_local }}" flat: true tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 11. Cleanup temp files on the remote host # ------------------------------------------------------------------ - name: Remove /tmp/full-domain.keytab from remote ansible.builtin.file: path: /tmp/full-domain.keytab state: absent tags: [spnego, keytab, gitea] - name: Remove /tmp/git-http.keytab from remote ansible.builtin.file: path: /tmp/git-http.keytab state: absent tags: [spnego, keytab, gitea] # =========================================================================== # Play 2 — Kubernetes: apply secret and restart gitea-spnego-proxy # =========================================================================== - name: Apply SPNEGO keytab secret and restart gitea-spnego-proxy in Kubernetes hosts: localhost gather_facts: false become: false vars: gitea_kube_context: prole-service-cluster gitea_spnego_namespace: gitea gitea_spnego_keytab_local: /tmp/git-http-fetched.keytab tasks: # ------------------------------------------------------------------ # 12. Apply keytab as Kubernetes secret gitea-krb5-keytab # # Uses --dry-run=client | apply to be fully idempotent — creates # on first run, patches on subsequent runs. # ------------------------------------------------------------------ - name: Apply gitea-krb5-keytab secret in namespace {{ gitea_spnego_namespace }} ansible.builtin.shell: cmd: | set -o pipefail kubectl --context={{ gitea_kube_context }} \ -n {{ gitea_spnego_namespace }} \ create secret generic gitea-krb5-keytab \ --from-file=http.keytab={{ gitea_spnego_keytab_local }} \ --dry-run=client -o yaml \ | kubectl --context={{ gitea_kube_context }} apply -f - executable: /bin/bash register: _gitea_secret changed_when: "'configured' in _gitea_secret.stdout or 'created' in _gitea_secret.stdout" failed_when: _gitea_secret.rc != 0 tags: [spnego, keytab, gitea] - name: Print kubectl apply output for secret ansible.builtin.debug: msg: "{{ _gitea_secret.stdout_lines }}" tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 13. Restart gitea-spnego-proxy deployment to pick up new keytab # ------------------------------------------------------------------ - name: Restart deployment/gitea-spnego-proxy ansible.builtin.command: argv: - kubectl - "--context={{ gitea_kube_context }}" - -n - "{{ gitea_spnego_namespace }}" - rollout - restart - deployment/gitea-spnego-proxy register: _gitea_restart changed_when: _gitea_restart.rc == 0 failed_when: _gitea_restart.rc != 0 tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 14. Wait for rollout to complete (timeout: 120s) # ------------------------------------------------------------------ - name: Wait for gitea-spnego-proxy rollout to complete ansible.builtin.command: argv: - kubectl - "--context={{ gitea_kube_context }}" - -n - "{{ gitea_spnego_namespace }}" - rollout - status - deployment/gitea-spnego-proxy - "--timeout=120s" register: _gitea_rollout changed_when: false failed_when: _gitea_rollout.rc != 0 tags: [spnego, keytab, gitea] - name: Print rollout status ansible.builtin.debug: msg: "{{ _gitea_rollout.stdout_lines }}" tags: [spnego, keytab, gitea] # ------------------------------------------------------------------ # 15. Smoke test — confirm SPNEGO challenge (HTTP 401 + Negotiate) # # A correctly deployed SPNEGO proxy returns 401 with # WWW-Authenticate: Negotiate on unauthenticated requests. # We check the status code; a 200 would mean auth is disabled, # anything other than 401 means the proxy is misconfigured. # ------------------------------------------------------------------ - name: Smoke test — https://git.prole.org/ should return HTTP 401 (SPNEGO challenge) ansible.builtin.command: argv: - curl - -s - -o - /dev/null - -w - "%{http_code}" - https://git.prole.org/ register: _gitea_smoke changed_when: false failed_when: _gitea_smoke.stdout | trim != "401" tags: [spnego, keytab, gitea] - name: Print smoke test result ansible.builtin.debug: msg: >- https://git.prole.org/ returned HTTP {{ _gitea_smoke.stdout | trim }}. SPNEGO challenge confirmed — proxy is issuing Negotiate header. tags: [spnego, keytab, gitea]