prole/infrastructure/playbooks/kerberos_trust_setup.yml
chrisfu ad1ecedb5e kerberos_trust_setup: pin trust account to RC4 only (msDS-Supported... 4)
The matching knoe-db change (commit `ff7546d`) re-keys the MIT-side
cross-realm krbtgts with `-e arcfour-hmac:normal` to dodge the
AES-salt mismatch between Samba and MIT. For the trust to actually
carry traffic, the Samba side must also offer only RC4 on the
trust account — otherwise the client picks AES, the two sides
derive different keys from the same password (different salt
conventions), and TGT decryption fails.

Change `msDS-SupportedEncryptionTypes` from 28 (RC4+AES128+AES256)
to 4 (RC4 only). RC4 has no salt, so keys derive from the password
alone and both sides converge.

Updated docstring + summary debug print to reflect the new value
and the why.

Tracked alongside the Junie brief at
~/dev/knoe-db/docs/plans/junie/kdc-trust-reset-repeatable.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 00:29:01 -07:00

414 lines
18 KiB
YAML

---
# kerberos_trust_setup.yml — Register the KNOE.LOCAL ↔ PROLE.ORG cross-realm
# Kerberos trust in Samba AD on myrddin.prole.org.
#
# This unblocks SSO from PROLE.ORG (the on-prem Samba AD realm) into KNOE.LOCAL
# (the in-cluster MIT KDC realm), which is required for chrisfu@PROLE.ORG →
# service@KNOE.LOCAL ticket flows targeting db.prole.org / pg_oauth and other
# cluster-hosted services that authenticate against the KNOE.LOCAL KDC.
#
# Why not `samba-tool domain trust create`?
# -----------------------------------------
# That command requires the remote side to be another writeable AD-style DC,
# and tries to discover it via DNS-SRV lookup. Our peer is the in-cluster MIT
# KDC at svc/auth.knoe-system — a plain Kerberos KDC, not AD — so the
# command bails with "Failed to find a writeable DC for domain KNOE.LOCAL".
#
# The supported Samba-AD ↔ MIT-KDC approach is to create the inter-realm TGT
# principal as an ordinary user account in Samba whose sAMAccountName equals
# `krbtgt/KNOE.LOCAL`, and to share that account's password with the MIT side.
# The MIT side already has the matching principal (created by init_kdc.sh in
# the knoe-db repo), keyed to the same trust_shared_password we pull from the
# in-cluster Secret knoe-system/knoe-kdc-secrets.
#
# PREREQUISITES
# -------------
# 1. Run AFTER init_knoe_users.sh / init_kdc.sh has run on the k3s cluster.
# The trust_shared_password used here must match the value stored in the
# in-cluster Secret knoe-system/knoe-kdc-secrets (key: trust_shared_password).
# The in-cluster MIT KDC must already have created the principal
# krbtgt/PROLE.ORG@KNOE.LOCAL
# and krbtgt/KNOE.LOCAL@PROLE.ORG
# before this playbook runs. (init_kdc.sh handles this.)
#
# 2. trust_kdc_ip must be the ClusterIP of the 'auth' Service in the knoe-system
# namespace. myrddin (the Samba DC) must be able to reach it — either because
# it is the k3s server node (and is therefore on the pod/service CIDR network)
# or because a static route has been added.
#
# 3. The Samba AD administrator password lives in the Ansible vault as
# vault_samba_dns_admin_pass
# (group_vars/ad_dc/vault.yml). Decrypt with --vault-password-file .vault_pass
# or `ANSIBLE_VAULT_PASSWORD_FILE=$PWD/.vault_pass`.
#
# DRY-RUN
# -------
# ansible-playbook infrastructure/playbooks/kerberos_trust_setup.yml \
# --check --diff \
# --vault-password-file .vault_pass \
# -e trust_shared_password=dummy
#
# FULL RUN (auto-resolves trust_shared_password from the cluster Secret)
# ---------------------------------------------------------------------
# ansible-playbook infrastructure/playbooks/kerberos_trust_setup.yml \
# --vault-password-file .vault_pass
#
# OVERRIDES
# ---------
# -e trust_kdc_ip=10.43.x.y # skip ClusterIP autoresolve
# -e trust_shared_password=... # skip Secret autoresolve
# -e samba_admin_password=... # override vault (e.g. CI without vault)
- name: Register KNOE.LOCAL cross-realm trust in Samba AD on myrddin
hosts: myrddin.prole.org
gather_facts: false
become: true
vars:
trust_realm: "KNOE.LOCAL"
samba_local_realm: "PROLE.ORG"
# sAMAccountName cannot contain a slash, so we store the inter-realm TGT
# account under an underscore-form name and explicitly set its
# userPrincipalName to the canonical Kerberos form below.
samba_account_name: "krbtgt_KNOE.LOCAL"
trust_principal: "krbtgt/KNOE.LOCAL@PROLE.ORG"
trust_kdc_ip: "" # auto-resolved below if empty
# Pull the trust password from the cluster Secret unless caller overrides.
trust_shared_password: "{{ lookup('env', 'PROLE_TRUST_SHARED_PASSWORD') | default('', true) }}"
# Admin password resolution order:
# 1. -e samba_admin_password=... (explicit override)
# 2. SAMBA_ADMIN_PASSWORD env var (legacy / CI)
# 3. vault_samba_dns_admin_pass (Ansible vault — preferred)
samba_admin_password: >-
{{ lookup('env', 'SAMBA_ADMIN_PASSWORD')
| default(hostvars[inventory_hostname].vault_samba_dns_admin_pass
| default(vault_samba_dns_admin_pass | default('')), true) }}
tasks:
# ------------------------------------------------------------------
# 0. Ensure required Debian packages are present
# (ldb-tools provides ldbmodify; we use it later to set UPN/SPN
# on the trust account.)
# ------------------------------------------------------------------
- name: Ensure ldb-tools is installed (provides ldbmodify)
ansible.builtin.apt:
name: ldb-tools
state: present
update_cache: true
cache_valid_time: 3600
# ------------------------------------------------------------------
# 0a. Resolve the ClusterIP of the in-cluster MIT KDC if not provided
# ------------------------------------------------------------------
- name: Resolve trust_kdc_ip from cluster if not provided
ansible.builtin.command:
cmd: kubectl -n knoe-system get svc auth -o jsonpath='{.spec.clusterIP}'
delegate_to: localhost
become: false
register: _kdc_clusterip
when: trust_kdc_ip == ""
changed_when: false
check_mode: false
- name: Set trust_kdc_ip fact from cluster lookup
ansible.builtin.set_fact:
trust_kdc_ip: "{{ _kdc_clusterip.stdout | trim }}"
when: trust_kdc_ip == "" and _kdc_clusterip is defined and _kdc_clusterip.stdout is defined
- name: Assert trust_kdc_ip is set
ansible.builtin.assert:
that:
- trust_kdc_ip != ""
fail_msg: >
trust_kdc_ip is empty. Provide it via --extra-vars trust_kdc_ip=...
or ensure 'kubectl -n knoe-system get svc auth' succeeds from the
control node.
# ------------------------------------------------------------------
# 0b. Resolve trust_shared_password from the cluster Secret if not provided
# ------------------------------------------------------------------
- name: Resolve trust_shared_password from knoe-system/knoe-kdc-secrets
ansible.builtin.shell:
cmd: >
set -o pipefail;
kubectl -n knoe-system get secret knoe-kdc-secrets
-o jsonpath='{.data.trust_shared_password}' | base64 -d
executable: /bin/bash
delegate_to: localhost
become: false
register: _trust_pw
when: trust_shared_password == ""
changed_when: false
check_mode: false
no_log: true
- name: Set trust_shared_password fact from cluster Secret
ansible.builtin.set_fact:
trust_shared_password: "{{ _trust_pw.stdout | trim }}"
when: trust_shared_password == "" and _trust_pw is defined and _trust_pw.stdout is defined
no_log: true
- name: Assert trust_shared_password is set
ansible.builtin.assert:
that:
- trust_shared_password | length > 0
fail_msg: >
trust_shared_password is empty. Provide it via -e trust_shared_password=...
or ensure the Secret knoe-system/knoe-kdc-secrets has key
'trust_shared_password' populated by init_kdc.sh.
- name: Assert samba_admin_password is set
ansible.builtin.assert:
that:
- samba_admin_password | length > 0
fail_msg: >
samba_admin_password is empty. Decrypt with --vault-password-file .vault_pass
or set SAMBA_ADMIN_PASSWORD / -e samba_admin_password=...
# ------------------------------------------------------------------
# 1. Idempotency probe — does the inter-realm krbtgt user exist?
# ------------------------------------------------------------------
# NOTE: `samba-tool domain trust create --type=external` only works
# against another AD-style writeable DC. For an MIT KDC peer
# (which KNOE.LOCAL is — it's the in-cluster Heimdal/MIT KDC, not AD),
# the documented approach is to create the inter-realm TGT
# principal as an ordinary user account in Samba and share its
# password with the MIT KDC. The MIT side already has
# krbtgt/KNOE.LOCAL@PROLE.ORG (created by init_kdc.sh) using the
# same trust_shared_password we just fetched from the cluster Secret.
- name: Check whether {{ samba_account_name }} 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
- name: Set fact — inter-realm krbtgt user already present
ansible.builtin.set_fact:
_trust_exists: "{{ samba_account_name in _samba_users.stdout }}"
- name: Report trust pre-existence
ansible.builtin.debug:
msg: >-
{{ samba_account_name }} user
{{ 'already present in Samba — will reset password to match cluster Secret.'
if _trust_exists else 'is missing — will create.' }}
# ------------------------------------------------------------------
# 2. Add KNOE.LOCAL realm block to /etc/krb5.conf on myrddin
# ------------------------------------------------------------------
- name: Ensure {{ trust_realm }} realm block is present in /etc/krb5.conf
ansible.builtin.blockinfile:
path: /etc/krb5.conf
marker: "# {mark} ANSIBLE MANAGED BLOCK — {{ trust_realm }}"
insertafter: '^\[realms\]'
block: |
{{ trust_realm }} = {
kdc = {{ trust_kdc_ip }}
admin_server = {{ trust_kdc_ip }}
}
- name: Ensure [domain_realm] mapping prole-cluster → {{ trust_realm }}
ansible.builtin.blockinfile:
path: /etc/krb5.conf
marker: "# {mark} ANSIBLE MANAGED BLOCK — domain_realm {{ trust_realm }}"
insertafter: '^\[domain_realm\]'
block: |
.knoe.local = {{ trust_realm }}
knoe.local = {{ trust_realm }}
# ------------------------------------------------------------------
# 3. Create / re-sync the inter-realm krbtgt user
# ------------------------------------------------------------------
# Samba's default domain password policy rejects the cluster-generated
# trust_shared_password (it's high-entropy random bytes and may not
# satisfy the "3 of 4 character categories" complexity rule).
# We work around that by temporarily disabling complexity, doing the
# create + setpassword, then restoring the previous complexity state.
# The restore lives in an `always:` block so even a hard failure in
# the middle leaves Samba's policy intact.
#
# The "no_log" wrapper protects the password but also hides the
# error message on failure; if a real run dies inside the block,
# re-run the failing command by hand to see stderr (see playbook
# header).
- name: Read current password complexity setting
ansible.builtin.command:
cmd: samba-tool domain passwordsettings show
register: _pw_policy
changed_when: false
check_mode: false
- name: Set fact — password complexity currently on?
ansible.builtin.set_fact:
_pw_complexity_was_on: "{{ 'Password complexity: on' in _pw_policy.stdout }}"
- name: Create / re-sync krbtgt user (with complexity temporarily off)
block:
- name: Disable password complexity for trust-account write
ansible.builtin.command:
cmd: samba-tool domain passwordsettings set --complexity=off
when: _pw_complexity_was_on
changed_when: true
- name: Create inter-realm krbtgt user {{ samba_account_name }}
ansible.builtin.command:
argv:
- samba-tool
- user
- create
- "{{ samba_account_name }}"
- "{{ trust_shared_password }}"
- "--description=Inter-realm TGT for {{ trust_realm }} (MIT KDC) cross-realm trust"
- "--use-username-as-cn"
- "-U"
- "administrator%{{ samba_admin_password }}"
no_log: true
register: _krbtgt_create
changed_when: _krbtgt_create.rc == 0
when: not _trust_exists
- name: Re-sync {{ samba_account_name }} password with cluster Secret
ansible.builtin.command:
argv:
- samba-tool
- user
- setpassword
- "{{ samba_account_name }}"
- "--newpassword={{ trust_shared_password }}"
- "-U"
- "administrator%{{ samba_admin_password }}"
no_log: true
register: _krbtgt_setpw
changed_when: _krbtgt_setpw.rc == 0
when: _trust_exists
always:
- name: Restore password complexity to its prior state
ansible.builtin.command:
cmd: samba-tool domain passwordsettings set --complexity=on
when: _pw_complexity_was_on
changed_when: true
# ------------------------------------------------------------------
# 5. Disable password expiry on the krbtgt account
# ------------------------------------------------------------------
- name: Disable password expiry on {{ samba_account_name }}
ansible.builtin.command:
argv:
- samba-tool
- user
- setexpiry
- "{{ samba_account_name }}"
- "--noexpiry"
- "-U"
- "administrator%{{ samba_admin_password }}"
no_log: true
register: _krbtgt_noexpiry
changed_when: _krbtgt_noexpiry.rc == 0
failed_when: _krbtgt_noexpiry.rc != 0
# ------------------------------------------------------------------
# 6. Set the Kerberos principal name (UPN + SPN) and pin the
# supported encryption types to RC4 only.
#
# sAMAccountName cannot contain "/" so we stored the account
# under {{ samba_account_name }} (krbtgt_KNOE.LOCAL). For the
# Samba KDC to issue tickets for the canonical Kerberos name
# krbtgt/KNOE.LOCAL@PROLE.ORG, we set both:
#
# userPrincipalName = krbtgt/KNOE.LOCAL@PROLE.ORG
# servicePrincipalName = krbtgt/KNOE.LOCAL
#
# msDS-SupportedEncryptionTypes = 4 = RC4 only.
# AES uses a salt as part of key derivation; Samba salts as
# <remote_realm>+UPN, MIT salts as <local_realm>+<principal>.
# The salts do not match across vendors, so AES keys derived
# from the same shared password come out different on each
# side and TGT decryption fails. RC4 has no salt — keys depend
# only on the password — so both sides converge. The matching
# MIT-side cross-realm krbtgts are created with
# `-e arcfour-hmac:normal` in knoe-db/etc/init_kdc.sh.
# ------------------------------------------------------------------
- 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
- name: Set UPN, SPN, and supported encryption types on {{ samba_account_name }}
ansible.builtin.shell:
cmd: |
set -euo pipefail
ldif=$(mktemp)
DN=$(samba-tool user show "{{ samba_account_name }}" \
-U "administrator%{{ samba_admin_password }}" \
| awk -F': ' '/^dn:/ {print $2; exit}')
if [ -z "$DN" ]; then
echo "Could not resolve DN for {{ samba_account_name }}" >&2
exit 1
fi
cat > "$ldif" <<EOF
dn: $DN
changetype: modify
replace: userPrincipalName
userPrincipalName: {{ trust_principal }}
-
replace: servicePrincipalName
servicePrincipalName: krbtgt/{{ trust_realm }}
-
replace: msDS-SupportedEncryptionTypes
msDS-SupportedEncryptionTypes: 4
EOF
"{{ _ldbmodify_path.stdout | trim }}" -H /var/lib/samba/private/sam.ldb "$ldif"
rm -f "$ldif"
no_log: true
register: _krbtgt_enctype
changed_when: "'Modified 1 records' in (_krbtgt_enctype.stdout | default(''))"
failed_when: _krbtgt_enctype.rc != 0
# ------------------------------------------------------------------
# 7. Smoke probe — confirm the user is visible to samba-tool
# ------------------------------------------------------------------
- name: Smoke probe — samba-tool user show {{ samba_account_name }}
ansible.builtin.command:
argv:
- samba-tool
- user
- show
- "{{ samba_account_name }}"
- "-U"
- "administrator%{{ samba_admin_password }}"
no_log: true
register: _krbtgt_show
changed_when: false
check_mode: false
# In --check mode the create step is skipped, so on a first dry
# run the user doesn't exist yet — don't fail the play in that
# case. Real runs still fail hard on rc!=0.
failed_when:
- _krbtgt_show.rc != 0
- not (ansible_check_mode and not _trust_exists)
- name: Print summary
ansible.builtin.debug:
msg:
- "{{ samba_account_name }} present in Samba (PROLE.ORG realm)."
- "Password is now in sync with knoe-system/knoe-kdc-secrets/trust_shared_password."
- "msDS-SupportedEncryptionTypes set to 4 (RC4 only — matches MIT cross-realm krbtgt)."
- "From a PROLE.ORG client try:"
- " kdestroy && kinit chrisfu@PROLE.ORG"
- " kvno krbtgt/{{ trust_realm }}@PROLE.ORG # cross-realm TGT only"
- " kvno HTTP/auth.knoe.dev@{{ trust_realm }} # full service ticket"
- " klist # expect krbtgt/{{ trust_realm }}@PROLE.ORG (slash form)"