mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:24:32 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
b245593b0c
commit
5077e1335d
@ -463,6 +463,73 @@ spec:
|
|||||||
EOF
|
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}"
|
ACTION="${1:-deploy}"
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
deploy|"") ;;
|
deploy|"") ;;
|
||||||
@ -484,5 +551,9 @@ fi
|
|||||||
log "Waiting for deployment rollout..."
|
log "Waiting for deployment rollout..."
|
||||||
kubectl -n "$NAMESPACE" rollout status deploy/gitea --timeout=10m
|
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:"
|
log "Done. Service endpoints:"
|
||||||
kubectl -n "$NAMESPACE" get svc gitea-http gitea-ssh
|
kubectl -n "$NAMESPACE" get svc gitea-http gitea-ssh
|
||||||
|
|||||||
@ -75,7 +75,8 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
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.
|
// Client is presenting a Kerberos ticket — run full SPNEGO negotiation.
|
||||||
// On success: X-WEBAUTH-USER is set by authedInner, Gitea auto-logs in.
|
// On success: X-WEBAUTH-USER is set by authedInner, Gitea auto-logs in.
|
||||||
// On failure: gokrb5 returns 401 (invalid ticket — correct to reject).
|
// On failure: gokrb5 returns 401 (invalid ticket — correct to reject).
|
||||||
@ -84,12 +85,25 @@ func main() {
|
|||||||
if rw.status == http.StatusUnauthorized {
|
if rw.status == http.StatusUnauthorized {
|
||||||
log.Printf("SPNEGO auth FAILED: from=%s host=%s path=%s token-prefix=%.20s",
|
log.Printf("SPNEGO auth FAILED: from=%s host=%s path=%s token-prefix=%.20s",
|
||||||
r.RemoteAddr, r.Host, r.URL.Path,
|
r.RemoteAddr, r.Host, r.URL.Path,
|
||||||
r.Header.Get("Authorization")[len("Negotiate "):])
|
auth[len("Negotiate "):])
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No Kerberos ticket — pass through so Gitea can show its own login page.
|
if auth != "" {
|
||||||
proxy.ServeHTTP(w, r)
|
// 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 <kerberos-token>.
|
||||||
|
// 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)
|
log.Printf("spnego-proxy listening on %s, upstream %s", listenAddr, upstreamURL)
|
||||||
|
|||||||
555
infrastructure/playbooks/gitea_spnego_keytab.yml
Normal file
555
infrastructure/playbooks/gitea_spnego_keytab.yml
Normal file
@ -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]
|
||||||
Loading…
Reference in New Issue
Block a user