Compare commits

...

2 Commits

Author SHA1 Message Date
chrisfu
0e937eb9db docs(readme): document prole.org fork — identity stack, live services, SSO
Add dedicated sections covering:
- prole.org cluster topology (myrddin/gandalf/merlin, namespaces, kubectl context)
- Live services table (db.prole.org, git.prole.org, pg.prole.org)
- Full identity/SSO stack: PROLE.ORG Kerberos → knoe-auth OIDC → Supabase Studio
- knoe-auth operational notes: Subject.callAs vs doAs, JDK 21 RC4 removal,
  AES-only keytab requirement, samba-tool exportkeytab --principal empty-keytab gotcha
- git.prole.org SPNEGO: proxy challenge logic, keytab rotation, arm64 build procedure
- Supabase Studio SSO: oauth2-proxy flow, basic-auth removal from Kong dashboard route
- Chrome SPNEGO policy deployment via make workstation
- Fork-specific files table

Update status section to 2026-05-28 with Identity/SSO commits clearly grouped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:14:53 -07:00
chrisfu
5077e1335d 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>
2026-05-28 05:19:59 -07:00
4 changed files with 840 additions and 26 deletions

218
README.md
View File

@ -297,33 +297,192 @@ Detailed setup docs, cluster procedures, and host-specific notes should live in
---
## prole.org deployment
This repository is the **prole.org fork** of the upstream knoe-db platform. It runs a production k3s cluster on a three-node ARM64 LAN (myrddin / gandalf / merlin) with Samba AD providing Kerberos identity for single sign-on across all platform services.
### Live services
| Service | URL | Auth |
|---|---|---|
| Supabase Studio | `https://db.prole.org` | SPNEGO → OIDC → Studio |
| Gitea | `https://git.prole.org` | SPNEGO (auto) or Gitea login |
| PostgreSQL | `pg.prole.org:5432` | mTLS + CNPG cert |
| SSH (Gitea) | `git.prole.org:3022` | SSH key |
### Cluster nodes
| Host | Role | IP |
|---|---|---|
| `myrddin.prole.org` | k3s server, Samba AD DC, container registry | 10.0.0.3 |
| `gandalf.prole.org` | k3s agent | 10.0.0.5 |
| `merlin.prole.org` | k3s agent | 10.0.0.6 |
Kubectl context: `prole-service-cluster` (kubeconfig at `knoe-k3s.kubeconfig`).
### Key namespaces
| Namespace | Contents |
|---|---|
| `knoe-system` | knoe-auth, Kong API gateway, KDC, Redis, Traefik |
| `gitea` | Gitea, gitea-spnego-proxy |
| `knoe-db` | CloudNativePG cluster |
| `supabase` | Supabase Studio, Kong (supabase), oauth2-proxy |
---
## Identity & SSO
All platform services use a unified Kerberos SSO stack. The flow from browser to service is:
```
Browser (Chrome, Safari, curl --negotiate)
│ kinit-obtained TGT from PROLE.ORG KDC (myrddin.prole.org)
Traefik (TLS termination, *.prole.org wildcard cert via ACME)
Kong API gateway (knoe-system/knoe-svc-kong)
│ routes by Host header
├── db.prole.org ──► oauth2-proxy ──► knoe-auth (OIDC) ──► Supabase Studio
└── git.prole.org ──► gitea-spnego-proxy (port 4000) ──► Gitea (port 3000)
```
### Kerberos realm
- **Realm:** `PROLE.ORG`
- **KDC / AD DC:** `myrddin.prole.org` (Samba 4, `10.0.0.3`)
- **Cross-realm trust:** `PROLE.ORG ↔ KNOE.LOCAL` (in-cluster MIT KDC for knoe-auth internal use)
- **Encryption:** AES256 + AES128 only (`msDS-SupportedEncryptionTypes=24`); RC4 disabled on all service accounts
### knoe-auth (OIDC provider — `api.prole.org/auth`)
knoe-auth is a Spring Boot 3 / JDK 21 OIDC authorization server that validates Kerberos SPNEGO tokens and issues OIDC tokens for downstream services.
**Key implementation details:**
| Detail | Value |
|---|---|
| Deployment | `knoe-system/knoe-auth` |
| SPNEGO endpoint | `https://api.prole.org/auth/spnego` |
| Keytab secret | `knoe-system/knoe-auth-keytab` (`HTTP/api.prole.org@PROLE.ORG`, AES-only) |
| JDK Subject API | `Subject.callAs()`**not** `Subject.doAs()` (removed in JDK 21) |
| RC4 | Hard-removed in JDK 21 JGSS — keytab and AD account must be AES-only |
| krb5.conf | Mounted via ConfigMap; `permitted_enctypes = aes256 aes128` (cannot re-enable RC4 here) |
Keytab provisioning (`etc/init_knoe_users.sh`):
```bash
# On myrddin — set AES-only, reset password, export and rekey keytab
sudo ldbmodify -H /var/lib/samba/private/sam.ldb <<EOF
dn: CN=knoe-auth,CN=Users,DC=prole,DC=org
changetype: modify
replace: msDS-SupportedEncryptionTypes
msDS-SupportedEncryptionTypes: 24
EOF
sudo samba-tool user setpassword knoe-auth --newpassword="$(openssl rand -base64 32 | tr -d '=/+' | head -c 40)"
# Export full domain keytab; rekey to HTTP/api.prole.org@PROLE.ORG in Python
# (samba-tool exportkeytab --principal=HTTP/... returns empty — see note below)
```
**Note:** `samba-tool domain exportkeytab --principal=HTTP/hostname` always produces an empty keytab on Samba 4 — the filter only matches UPN format, not SPN format. The correct procedure is to export the full domain keytab and rekey the AES entries to the SPN principal name using the Python script embedded in `infrastructure/playbooks/gitea_spnego_keytab.yml` (same logic applies for all HTTP service principals).
### Supabase Studio SSO (`db.prole.org`)
```
Browser → Kong → oauth2-proxy → knoe-auth OIDC → oauth2-proxy (cookie set)
→ Kong (upstream to studio)
→ Supabase Studio
```
- **oauth2-proxy** handles the OIDC callback and sets a session cookie (`_oauth2_proxy`)
- **Supabase Kong** dashboard route: `cors` plugin only — `basic-auth` plugin **removed** (it blocked oauth2-proxy's proxied requests)
- The `basic-auth` removal is applied directly to the live ConfigMap and must be re-applied after any `helm upgrade` of the supabase chart
Supabase Studio access:
```bash
# Via browser (automatic SPNEGO with Chrome policy)
open https://db.prole.org
# Check oauth2-proxy is passing through correctly
curl -I https://db.prole.org/oauth2/sign_in
```
Chrome SPNEGO policy (`/Library/Managed Preferences/com.google.Chrome.plist`):
```xml
<key>AuthServerAllowlist</key>
<string>*.prole.org</string>
<key>AuthNegotiateDelegateAllowlist</key>
<string>*.prole.org</string>
```
Deploy to a Mac workstation:
```bash
make workstation
# or: ansible-playbook infrastructure/playbooks/workstation_kerberos.yml --ask-become-pass
```
### git.prole.org SPNEGO (Gitea)
```
Browser/curl → Kong → gitea-spnego-proxy (:4000) → Gitea (:3000)
├─ No Authorization header → 401 + WWW-Authenticate: Negotiate
├─ Authorization: Negotiate <token> → SPNEGO validate → X-WEBAUTH-USER → Gitea auto-login
└─ Authorization: Basic/token → pass-through → Gitea auth
```
**Component:** `gitea/spnego-proxy/` — Go binary using `gokrb5/v8`, built for `linux/arm64`.
| Detail | Value |
|---|---|
| Image | `myrddin.prole.org:5000/gitea-spnego-proxy:latest` |
| Keytab secret | `gitea/gitea-krb5-keytab` (`HTTP/git.prole.org@PROLE.ORG`, AES-only, KVNO 4) |
| AD account | `CN=gitea-http,CN=Users,DC=prole,DC=org` (`msDS-SupportedEncryptionTypes=24`) |
| Rebuild | Build on myrddin (native arm64); `docker build` then `docker push localhost:5000/...` |
Keytab rotation:
```bash
ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml -e force_keytab_reset=true
```
Manual rebuild (if source changed):
```bash
# Transfer source and build on myrddin (all nodes are arm64)
tar -czf /tmp/src.tar.gz gitea/spnego-proxy/ && scp /tmp/src.tar.gz myrddin:/tmp/
ssh myrddin "mkdir -p /tmp/spnego-build && tar -xzf /tmp/src.tar.gz -C /tmp/spnego-build && \
sudo docker build -t localhost:5000/gitea-spnego-proxy:latest /tmp/spnego-build/gitea/spnego-proxy/ && \
sudo docker push localhost:5000/gitea-spnego-proxy:latest"
kubectl --context=prole-service-cluster -n gitea rollout restart deployment/gitea-spnego-proxy
```
Smoke test:
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://git.prole.org/ # → 401 (challenge)
curl -s -o /dev/null -w "%{http_code}\n" --negotiate -u : https://git.prole.org/ # → 200 (authed)
```
---
## Status
Knoe is an actively evolving platform stack aimed at practical self-hosted, edge, and cloud operation.
**As of 2026-05-28** the following work has shipped on the prole.org fork:
**As of 2026-05-23** the following work has shipped:
- `fix(cfg)``_validate_cfg_values` prevents MagicMock reprs from leaking into `conf/*.cfg` (`ef20c8a`)
- `feat(env)``gke_` kubecontext prefix auto-detected as `prod`; `status.py` context helpers (`da0fd2c`)
- `refactor(mock_val)``prole_*` shell lib and cfg tooling renamed to `knoe_*` namespace (`11064cb`)
- `feat(mock_val)` — init scripts rewritten; 10+ new service init scripts added (`3f96f66`)
- `feat(mock_val)` — diagnostics, utilities, certs, and operational scripts added (`3943d1b`)
- `feat(scripts)` — upstream knoe-db sync script + procedure doc (`decb9a5`)
- `feat(infrastructure)` — Pi-hole DNS flush Ansible playbook (`3ada16c`)
- `docs` — shipped briefs moved to `docs/completed/`; `conf/service/knoe.cfg` added (`73dce03`)
- `feat(mock_val)``common_core_lib` mode-aware config path helper (`d806905`)
- `fix(conf)` — MagicMock contamination removed from `conf/k3d.cfg` and `conf/k3s.cfg` (`568f03b`)
- `docs(branches)` — upstream knoe-db/20260523 review doc, branch index, and Junie integration brief (`0e822ea`)
- `feat(pg-knoe-auth)` — upstream PostgreSQL JWT auth extension imported; compiled in `knoe-db` image (`57886f9`)
- `docs(branches)` — Task 1 marked complete in branch index (`e5cf9b1`)
**Identity / SSO (May 2026)**
- `feat(gitea)` — SPNEGO Kerberos SSO for `git.prole.org`; proxy issues `WWW-Authenticate: Negotiate` challenge; `gitea_spnego_keytab.yml` Ansible playbook for full provisioning lifecycle (`5077e13`)
- `fix(ansible)` — workstation install script + `make workstation` target for Chrome SPNEGO policy on personal Macs (`b245593`)
- `fix(spnego)``Subject.callAs()`, AES-only keytab, and `krb5.conf` sync for JDK 21 knoe-auth (`c1d2a91`)
- `feat(oidc)` — knoe-auth routing through Kong; Flyway schema baseline for clean OIDC DB (`9523045`)
- `feat(prole)` — knoe-auth bootstrap on k3s; tenant onboarding; cluster stabilisation (`cf33342`)
Working tree is clean. Upstream sync tooling is operational; `upstream/knoe-db/20260523` review is in progress (Task 1 ✅).
**Infrastructure (earlier)**
- `fix(cfg)``_validate_cfg_values` prevents MagicMock reprs from leaking into `conf/*.cfg`
- `feat(env)``gke_` kubecontext prefix auto-detected as `prod`; `status.py` context helpers
- `refactor(mock_val)``prole_*` shell lib and cfg tooling renamed to `knoe_*` namespace
- `feat(mock_val)` — init scripts rewritten; 10+ new service init scripts added
- `feat(scripts)` — upstream knoe-db sync script + procedure doc
- `feat(pg-knoe-auth)` — upstream PostgreSQL JWT auth extension imported; compiled in `knoe-db` image
Expect the architecture to continue being refined toward:
- cleaner bootstrapping
- better shard isolation
- smoother rejoin/reset behavior for cluster nodes
- clearer service boundaries
- improved onboarding and operations documentation
Working tree is clean. Browser SPNEGO SSO is live on `db.prole.org` and `git.prole.org`.
---
@ -347,6 +506,21 @@ Upstream changes are pulled into a dated review branch (`upstream/knoe-db/YYYYMM
See [`docs/upstream-knoe-db-sync.md`](docs/upstream-knoe-db-sync.md) for the full review-and-merge procedure.
### Fork-specific files
Files added or substantially modified in the prole.org fork (not present or not relevant upstream):
| Path | Purpose |
|---|---|
| `infrastructure/` | Ansible roles, playbooks, and inventory for the prole.org cluster |
| `infrastructure/playbooks/gitea_spnego_keytab.yml` | Gitea SPNEGO keytab provisioning |
| `infrastructure/playbooks/workstation_kerberos.yml` | Chrome SPNEGO policy + krb5.conf for macOS workstations |
| `infrastructure/playbooks/kerberos_trust_setup.yml` | PROLE.ORG ↔ KNOE.LOCAL cross-realm trust |
| `infrastructure/bin/install_workstation.sh` | Wrapper for workstation Ansible (adds `--ask-become-pass`) |
| `gitea/spnego-proxy/` | Go SPNEGO reverse proxy for `git.prole.org` |
| `deploy/opentofu/k3s/manifests/knoe/gitea-spnego-proxy.yaml` | k8s deployment for the SPNEGO proxy |
| `conf/k3s.cfg` | prole.org k3s cluster configuration |
---
## License

View File

@ -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

View File

@ -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)

View 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]