From 5077e1335d391323b663533bf81816e57dac607e Mon Sep 17 00:00:00 2001 From: chrisfu Date: Thu, 28 May 2026 05:19:59 -0700 Subject: [PATCH] feat(gitea): SPNEGO Kerberos SSO for git.prole.org Three-part fix to make browser and curl SPNEGO auth work end-to-end: 1. spnego-proxy: issue WWW-Authenticate: Negotiate challenge when no Authorization header is present so Chrome (with AuthServerAllowlist) and curl --negotiate automatically present Kerberos tokens. Previously the proxy only validated tokens if the client proactively sent them. Pass-through preserved for non-Negotiate schemes (Basic/token) so git CLI users with PATs continue to work via Gitea own auth. 2. gitea_spnego_keytab.yml: new Ansible playbook that provisions the gitea-http AD account (AES-only, msDS-SupportedEncryptionTypes=24), registers SPN HTTP/git.prole.org, resets the password to derive fresh AES keys, exports the domain keytab, and rekeys it to principal name HTTP/git.prole.org@PROLE.ORG that gokrb5 needs for keytab lookup. Key lesson: samba-tool exportkeytab --principal=HTTP/... returns empty; must export full domain keytab and rekey in Python. 3. init_gitea.sh: add setup_gitea_spnego() calling the Ansible playbook in k3s mode as part of the standard deploy flow, with inline notes on every non-obvious constraint discovered during this work. Co-Authored-By: Claude Sonnet 4.6 --- etc/init_gitea.sh | 71 +++ gitea/spnego-proxy/main.go | 22 +- .../playbooks/gitea_spnego_keytab.yml | 555 ++++++++++++++++++ 3 files changed, 644 insertions(+), 4 deletions(-) create mode 100644 infrastructure/playbooks/gitea_spnego_keytab.yml diff --git a/etc/init_gitea.sh b/etc/init_gitea.sh index 523999d..14beece 100755 --- a/etc/init_gitea.sh +++ b/etc/init_gitea.sh @@ -463,6 +463,73 @@ spec: EOF } +setup_gitea_spnego() { + # Provision the SPNEGO keytab and deploy the gitea-spnego-proxy sidecar. + # + # In k3s mode: runs the Ansible playbook gitea_spnego_keytab.yml which: + # - Creates/updates the gitea-http AD account with AES-only encryption + # - Registers the SPN HTTP/git.prole.org on that account + # - Exports and rekeys the keytab to HTTP/git.prole.org@PROLE.ORG + # - Applies the k8s secret gitea-krb5-keytab in the gitea namespace + # - Restarts the gitea-spnego-proxy deployment + # + # Key knowledge captured here: + # - samba-tool domain exportkeytab with --principal=HTTP/git.prole.org + # produces an empty keytab. The account principal (gitea-http@PROLE.ORG) + # must be exported via the full domain keytab and rekeyed in Python. + # - msDS-SupportedEncryptionTypes=24 must be set before the password reset + # to ensure the KDC generates AES keys (16=AES256, 8=AES128, sum=24). + # - gokrb5 v8 matches keytab entries by principal name components, so the + # keytab MUST contain HTTP/git.prole.org@PROLE.ORG (not gitea-http@PROLE.ORG) + # for the SPNEGO token decryption to succeed. + # - The proxy must issue WWW-Authenticate: Negotiate on requests with no + # Authorization header; without the challenge Chrome never sends a token. + # - Nodes are arm64 — build the proxy image on myrddin, not cross-compiled. + # + # Keytab rotation (e.g. after AD password policy change): + # ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml \ + # -e force_keytab_reset=true + + # Apply the spnego-proxy manifest (idempotent — already defined in the k3s + # manifests directory; this ensures it's present after a fresh cluster build). + local spnego_manifest="${SCRIPT_DIR}/../deploy/opentofu/k3s/manifests/knoe/gitea-spnego-proxy.yaml" + if [[ -f "$spnego_manifest" ]]; then + log "Applying gitea-spnego-proxy manifest..." + kubectl apply -f "$spnego_manifest" >/dev/null 2>&1 || warn "gitea-spnego-proxy manifest apply failed (may already be up-to-date)" + else + warn "gitea-spnego-proxy manifest not found at $spnego_manifest — skipping" + fi + + if [[ "$MODE" != "k3s" ]]; then + warn "SPNEGO keytab provisioning via Ansible only runs in k3s mode (current: $MODE)." + warn "For other modes, manually run:" + warn " ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml" + return 0 + fi + + if ! command -v ansible-playbook >/dev/null 2>&1; then + warn "ansible-playbook not found — skipping SPNEGO keytab provisioning." + warn "Run manually: ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml" + return 0 + fi + + local playbook="${SCRIPT_DIR}/../infrastructure/playbooks/gitea_spnego_keytab.yml" + if [[ ! -f "$playbook" ]]; then + warn "Playbook not found at $playbook — skipping SPNEGO keytab provisioning" + return 0 + fi + + log "Running SPNEGO keytab provisioning via Ansible..." + if ansible-playbook "$playbook" \ + -e "gitea_kube_context=${KUBECONTEXT:-prole-service-cluster}" \ + -e "gitea_spnego_namespace=${NAMESPACE}"; then + log "✓ Gitea SPNEGO keytab provisioned — SPNEGO challenge active on git.prole.org" + else + warn "SPNEGO keytab provisioning failed. Gitea will still work via its own login page." + warn "Retry: ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml -e force_keytab_reset=true" + fi +} + ACTION="${1:-deploy}" case "$ACTION" in deploy|"") ;; @@ -484,5 +551,9 @@ fi log "Waiting for deployment rollout..." kubectl -n "$NAMESPACE" rollout status deploy/gitea --timeout=10m +# Provision the SPNEGO proxy keytab and verify the Negotiate challenge is live. +# This step is idempotent — safe to run on upgrades/re-deploys. +setup_gitea_spnego + log "Done. Service endpoints:" kubectl -n "$NAMESPACE" get svc gitea-http gitea-ssh diff --git a/gitea/spnego-proxy/main.go b/gitea/spnego-proxy/main.go index 122929c..523d286 100644 --- a/gitea/spnego-proxy/main.go +++ b/gitea/spnego-proxy/main.go @@ -75,7 +75,8 @@ func main() { }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.Header.Get("Authorization"), "Negotiate ") { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Negotiate ") { // Client is presenting a Kerberos ticket — run full SPNEGO negotiation. // On success: X-WEBAUTH-USER is set by authedInner, Gitea auto-logs in. // On failure: gokrb5 returns 401 (invalid ticket — correct to reject). @@ -84,12 +85,25 @@ func main() { if rw.status == http.StatusUnauthorized { log.Printf("SPNEGO auth FAILED: from=%s host=%s path=%s token-prefix=%.20s", r.RemoteAddr, r.Host, r.URL.Path, - r.Header.Get("Authorization")[len("Negotiate "):]) + auth[len("Negotiate "):]) } return } - // No Kerberos ticket — pass through so Gitea can show its own login page. - proxy.ServeHTTP(w, r) + if auth != "" { + // Other auth scheme (Basic, token) — pass through to Gitea. + // This allows git CLI users with personal access tokens or passwords to + // authenticate directly via Gitea's own credential validation. + log.Printf("pass-through auth: from=%s scheme=%.6s...", r.RemoteAddr, auth) + proxy.ServeHTTP(w, r) + return + } + // No auth header — issue a SPNEGO challenge. + // Browsers with Chrome SPNEGO policy (AuthServerAllowlist=*.prole.org) will + // automatically respond with Authorization: Negotiate . + // git CLI users with PATs will retry with Basic auth (handled above). + w.Header().Set("WWW-Authenticate", "Negotiate") + http.Error(w, "Authentication required (SPNEGO/Kerberos)", http.StatusUnauthorized) + log.Printf("SPNEGO challenge issued: from=%s host=%s path=%s", r.RemoteAddr, r.Host, r.URL.Path) }) log.Printf("spnego-proxy listening on %s, upstream %s", listenAddr, upstreamURL) diff --git a/infrastructure/playbooks/gitea_spnego_keytab.yml b/infrastructure/playbooks/gitea_spnego_keytab.yml new file mode 100644 index 0000000..2bdaeb5 --- /dev/null +++ b/infrastructure/playbooks/gitea_spnego_keytab.yml @@ -0,0 +1,555 @@ +--- +# 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]