mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 09:24:35 +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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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 <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)
|
||||
|
||||
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