feat(sssd): authenticate NixOS hosts against Authentik LDAP via agenix #55
@@ -52,6 +52,33 @@ The home config is also exposed for use beyond these hosts:
|
|||||||
(`inputs.<this>.homeModules.default`). Consumers must supply the module args
|
(`inputs.<this>.homeModules.default`). Consumers must supply the module args
|
||||||
these expect: `inputs` always, `identity` for git/desktop, `portable` for sway.
|
these expect: `inputs` always, `identity` for git/desktop, `portable` for sway.
|
||||||
|
|
||||||
|
## Directory authentication (SSSD → Authentik LDAP)
|
||||||
|
|
||||||
|
Every NixOS host authenticates users against the Authentik LDAP outpost via
|
||||||
|
SSSD, implemented in [`modules/sssd.nix`](./modules/sssd.nix) and enabled by
|
||||||
|
default through the `services.authentikLdap.enable` option (added to
|
||||||
|
`baseModules`). The **EDaaS** WSL box opts out
|
||||||
|
(`services.authentikLdap.enable = false`) as a work-managed environment; the
|
||||||
|
macOS host is unaffected (SSSD is Linux-only).
|
||||||
|
|
||||||
|
- Connects over LDAPS to `ldap.lyrapup.pet:636`, search base
|
||||||
|
`dc=ldap,dc=goauthentik,dc=io`, binding as
|
||||||
|
`cn=sssd-bind,ou=users,dc=ldap,dc=goauthentik,dc=io`.
|
||||||
|
- The schema mappings match Authentik's non-standard object classes
|
||||||
|
(`goauthentik.io/ldap/user`, `goauthentik.io/ldap/group`) over the POSIX
|
||||||
|
attributes (`uid`, `uidNumber`, `gidNumber`, `homeDirectory`).
|
||||||
|
- Home directories are created on first login (`pam_mkhomedir`).
|
||||||
|
|
||||||
|
### Secrets (agenix)
|
||||||
|
|
||||||
|
The LDAP bind credential is an [agenix](https://github.com/ryantm/agenix)
|
||||||
|
secret, decrypted at activation with each host's SSH host key. The decrypted
|
||||||
|
plaintext is a full `sssd.conf` drop-in delivered to
|
||||||
|
`/etc/sssd/conf.d/01-ldap-authtok.conf`, so the password never enters the Nix
|
||||||
|
store. Owner setup (host recipient keys, encrypting the bind password, DNS for
|
||||||
|
`ldap.lyrapup.pet`, rebuild) is documented in
|
||||||
|
[`secrets/README.md`](./secrets/README.md).
|
||||||
|
|
||||||
## Applying
|
## Applying
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
url = "github:cachix/git-hooks.nix";
|
url = "github:cachix/git-hooks.nix";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
# agenix: age-encrypted secrets, decrypted at activation with each host's
|
||||||
|
# SSH host key. Provides the SSSD LDAP bind credential (secrets/, see
|
||||||
|
# modules/sssd.nix). The darwin module is intentionally unused (SSSD is
|
||||||
|
# Linux-only).
|
||||||
|
agenix = {
|
||||||
|
url = "github:ryantm/agenix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
inputs.home-manager.follows = "home-manager";
|
||||||
|
};
|
||||||
# Declarative Neovim (the editor; see home/editor.nix). Release
|
# Declarative Neovim (the editor; see home/editor.nix). Release
|
||||||
# branch matched to the pinned nixpkgs (26.05); follows our nixpkgs to keep a
|
# branch matched to the pinned nixpkgs (26.05); follows our nixpkgs to keep a
|
||||||
# single nixpkgs in the closure. editor.nix sets programs.nixvim.nixpkgs.source
|
# single nixpkgs in the closure. editor.nix sets programs.nixvim.nixpkgs.source
|
||||||
@@ -123,7 +132,9 @@
|
|||||||
./modules/users.nix
|
./modules/users.nix
|
||||||
./modules/common-nixos.nix
|
./modules/common-nixos.nix
|
||||||
./modules/features.nix
|
./modules/features.nix
|
||||||
|
./modules/sssd.nix
|
||||||
commonModule
|
commonModule
|
||||||
|
inputs.agenix.nixosModules.default
|
||||||
home-manager.nixosModules.home-manager
|
home-manager.nixosModules.home-manager
|
||||||
{
|
{
|
||||||
home-manager.useGlobalPkgs = true;
|
home-manager.useGlobalPkgs = true;
|
||||||
|
|||||||
@@ -62,6 +62,11 @@
|
|||||||
|
|
||||||
features.swayDesktop.enable = false;
|
features.swayDesktop.enable = false;
|
||||||
|
|
||||||
|
# Opt out of fleet-wide SSSD/Authentik LDAP auth: this is a work-managed WSL
|
||||||
|
# box, not part of the personal directory. Every other NixOS host inherits the
|
||||||
|
# default-true from modules/sssd.nix.
|
||||||
|
services.authentikLdap.enable = false;
|
||||||
|
|
||||||
# NOTE: this user's systemd --user lingering -- so the home-manager renovate
|
# NOTE: this user's systemd --user lingering -- so the home-manager renovate
|
||||||
# timer fires without an open login session -- is enabled from the host table
|
# timer fires without an open login session -- is enabled from the host table
|
||||||
# in flake.nix (users.emmathorpe.linger = true) and applied by
|
# in flake.nix (users.emmathorpe.linger = true) and applied by
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Authentik LDAP authentication for NixOS hosts.
|
||||||
|
#
|
||||||
|
# Wires SSSD (System Security Services Daemon) to the Authentik LDAP outpost so
|
||||||
|
# every Linux host authenticates users against the same directory that backs the
|
||||||
|
# SSO stack. Enabled by default on every NixOS host via baseModules; the EDaaS
|
||||||
|
# WSL box opts out (services.authentikLdap.enable = false) because it is a
|
||||||
|
# work-managed Windows-hosted environment.
|
||||||
|
#
|
||||||
|
# The Authentik LDAP provider exposes NON-standard object classes/attributes
|
||||||
|
# (goauthentik.io/ldap/user, goauthentik.io/ldap/group) alongside the POSIX
|
||||||
|
# attributes (uid, uidNumber, gidNumber, homeDirectory), so the schema mappings
|
||||||
|
# below are explicit rather than relying on an RFC2307 default.
|
||||||
|
#
|
||||||
|
# The bind password is NOT inlined: services.sssd.config renders to the world-
|
||||||
|
# readable Nix store, so the credential is delivered out-of-band by agenix as an
|
||||||
|
# sssd.conf drop-in under /etc/sssd/conf.d/ (SSSD merges conf.d/*.conf after the
|
||||||
|
# main file). See secrets/README.md.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.services.authentikLdap;
|
||||||
|
|
||||||
|
# Directory coordinates for the Authentik LDAP provider.
|
||||||
|
ldapUri = "ldaps://ldap.lyrapup.pet:636";
|
||||||
|
searchBase = "dc=ldap,dc=goauthentik,dc=io";
|
||||||
|
bindDn = "cn=sssd-bind,ou=users,dc=ldap,dc=goauthentik,dc=io";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.authentikLdap.enable =
|
||||||
|
lib.mkEnableOption "SSSD authentication against the Authentik LDAP outpost"
|
||||||
|
// {
|
||||||
|
default = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
services.sssd = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
# Non-secret sssd.conf. The bind password is injected separately via the
|
||||||
|
# agenix conf.d drop-in (ldap_default_authtok lives there, not here) to
|
||||||
|
# keep it out of the Nix store.
|
||||||
|
config = ''
|
||||||
|
[sssd]
|
||||||
|
config_file_version = 2
|
||||||
|
services = nss, pam
|
||||||
|
domains = default
|
||||||
|
|
||||||
|
[nss]
|
||||||
|
# Do not walk the whole directory for `getent passwd` etc.
|
||||||
|
filter_users = root
|
||||||
|
filter_groups = root
|
||||||
|
|
||||||
|
[pam]
|
||||||
|
|
||||||
|
[domain/default]
|
||||||
|
# --- Providers --------------------------------------------------------
|
||||||
|
id_provider = ldap
|
||||||
|
auth_provider = ldap
|
||||||
|
chpass_provider = none
|
||||||
|
access_provider = permit
|
||||||
|
|
||||||
|
# --- Connection -------------------------------------------------------
|
||||||
|
ldap_uri = ${ldapUri}
|
||||||
|
ldap_search_base = ${searchBase}
|
||||||
|
ldap_default_bind_dn = ${bindDn}
|
||||||
|
ldap_default_authtok_type = password
|
||||||
|
# ldap_default_authtok is supplied by the agenix drop-in in conf.d.
|
||||||
|
|
||||||
|
# --- TLS (LDAPS on 636; no StartTLS) ---------------------------------
|
||||||
|
ldap_id_use_start_tls = false
|
||||||
|
ldap_tls_reqcert = demand
|
||||||
|
|
||||||
|
# --- Schema: Authentik LDAP provider ---------------------------------
|
||||||
|
# Authentik returns DN-valued group membership (member/memberOf), so
|
||||||
|
# rfc2307bis (not rfc2307) is the correct base schema.
|
||||||
|
ldap_schema = rfc2307bis
|
||||||
|
|
||||||
|
# Users: goauthentik.io/ldap/user, keyed by uid; POSIX attrs are
|
||||||
|
# standard names (uidNumber/gidNumber/homeDirectory).
|
||||||
|
ldap_user_object_class = goauthentik.io/ldap/user
|
||||||
|
ldap_user_name = uid
|
||||||
|
ldap_user_uid_number = uidNumber
|
||||||
|
ldap_user_gid_number = gidNumber
|
||||||
|
ldap_user_home_directory = homeDirectory
|
||||||
|
ldap_user_gecos = displayName
|
||||||
|
ldap_user_shell = loginShell
|
||||||
|
|
||||||
|
# Groups: goauthentik.io/ldap/group, keyed by cn.
|
||||||
|
ldap_group_object_class = goauthentik.io/ldap/group
|
||||||
|
ldap_group_name = cn
|
||||||
|
ldap_group_gid_number = gidNumber
|
||||||
|
ldap_group_member = member
|
||||||
|
|
||||||
|
# --- Behaviour --------------------------------------------------------
|
||||||
|
cache_credentials = true
|
||||||
|
enumerate = false
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# agenix delivers the bind password as an sssd.conf drop-in. The decrypted
|
||||||
|
# plaintext IS a valid conf.d snippet:
|
||||||
|
#
|
||||||
|
# [domain/default]
|
||||||
|
# ldap_default_authtok = <the bind password>
|
||||||
|
#
|
||||||
|
# SSSD requires conf.d files to be root-owned and 0600 or it ignores them.
|
||||||
|
age.secrets.ldap-bind = {
|
||||||
|
file = ../secrets/ldap-bind.age;
|
||||||
|
path = "/etc/sssd/conf.d/01-ldap-authtok.conf";
|
||||||
|
owner = "root";
|
||||||
|
group = "root";
|
||||||
|
mode = "0600";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Restart SSSD when the credential drop-in changes. agenix writes secrets in
|
||||||
|
# a system activation script that runs before systemd (re)starts services on
|
||||||
|
# a `switch`, so the file is present by the time sssd starts; the trigger
|
||||||
|
# picks up rotations of the bind password.
|
||||||
|
systemd.services.sssd.restartTriggers = [ config.age.secrets.ldap-bind.path ];
|
||||||
|
|
||||||
|
# Create home directories on first login for LDAP users (they have no
|
||||||
|
# locally-provisioned home). NixOS wires nss + the SSSD PAM stack when
|
||||||
|
# services.sssd.enable is true; mkHomeDir adds pam_mkhomedir to it.
|
||||||
|
security.pam.services.login.makeHomeDir = true;
|
||||||
|
security.pam.services.sshd.makeHomeDir = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Secrets (agenix)
|
||||||
|
|
||||||
|
Encrypted secrets for the fleet, managed with [agenix](https://github.com/ryantm/agenix).
|
||||||
|
|
||||||
|
Each secret is an age-encrypted file (`*.age`) encrypted to a set of recipient
|
||||||
|
public keys declared in [`secrets.nix`](./secrets.nix). A host decrypts its
|
||||||
|
secrets at activation using its SSH **host** key
|
||||||
|
(`/etc/ssh/ssh_host_ed25519_key`), so every host that must read a secret has to
|
||||||
|
be listed as a recipient for it.
|
||||||
|
|
||||||
|
`secrets.nix` is read only by the `agenix` CLI. It is never imported into the
|
||||||
|
NixOS evaluation.
|
||||||
|
|
||||||
|
## Secrets in this repo
|
||||||
|
|
||||||
|
| File | Purpose | Recipients |
|
||||||
|
| --------------- | ----------------------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| `ldap-bind.age` | SSSD → Authentik LDAP bind credential, as an `sssd.conf` drop-in snippet | all SSSD-enabled hosts (not EDaaS) |
|
||||||
|
|
||||||
|
Consumed by [`modules/sssd.nix`](../modules/sssd.nix) via
|
||||||
|
`age.secrets.ldap-bind.path`, which places the decrypted snippet at
|
||||||
|
`/etc/sssd/conf.d/01-ldap-authtok.conf`.
|
||||||
|
|
||||||
|
> **`ldap-bind.age` is not committed yet.** Only `ldap-bind.age.PLACEHOLDER`
|
||||||
|
> ships in this change (real host recipient keys and the real password were not
|
||||||
|
> available when it was written). Follow the steps below to create the real
|
||||||
|
> secret, then delete the `.PLACEHOLDER`.
|
||||||
|
|
||||||
|
## Owner setup checklist
|
||||||
|
|
||||||
|
Run these once (per new host or when the bind password rotates):
|
||||||
|
|
||||||
|
### 1. Collect host recipient keys
|
||||||
|
|
||||||
|
On each SSSD-enabled host (all Linux hosts **except** EDaaS):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cat /etc/ssh/ssh_host_ed25519_key.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste each value into the matching placeholder in `secrets.nix`, replacing the
|
||||||
|
`AAAA_PLACEHOLDER_REPLACE_ME_*` strings. (Optionally uncomment and set `admin`
|
||||||
|
to an operator user key so the secret can be edited off-host.)
|
||||||
|
|
||||||
|
### 2. Encrypt the bind password
|
||||||
|
|
||||||
|
The plaintext must be a **full sssd.conf drop-in snippet**, because SSSD cannot
|
||||||
|
read `ldap_default_authtok` from a separate file — it only merges `conf.d/*.conf`.
|
||||||
|
The content is exactly:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[domain/default]
|
||||||
|
ldap_default_authtok = <the sssd-bind service-account password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the password of the `sssd-bind` (Terraform: `sssd-bind`) Authentik LDAP
|
||||||
|
service account. Then, from the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Requires the agenix CLI: `nix run github:ryantm/agenix -- -e secrets/ldap-bind.age`
|
||||||
|
cd secrets
|
||||||
|
agenix -e ldap-bind.age
|
||||||
|
```
|
||||||
|
|
||||||
|
An `$EDITOR` opens; paste the two-line snippet above, save, quit. agenix writes
|
||||||
|
the encrypted `ldap-bind.age`. Commit it and delete `ldap-bind.age.PLACEHOLDER`.
|
||||||
|
|
||||||
|
### 3. Rekey after changing recipients
|
||||||
|
|
||||||
|
If you add/remove hosts in `secrets.nix`, re-encrypt every secret to the new
|
||||||
|
recipient set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd secrets
|
||||||
|
agenix -r
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. DNS
|
||||||
|
|
||||||
|
`ldap.lyrapup.pet` must resolve to the Authentik LDAP outpost and serve LDAPS on
|
||||||
|
port 636 with a certificate the hosts trust (`ldap_tls_reqcert = demand`). If the
|
||||||
|
cert is not from a system-trusted CA, add it to the hosts' trust store
|
||||||
|
(`security.pki.certificateFiles`) or relax `ldap_tls_reqcert` in
|
||||||
|
`modules/sssd.nix`.
|
||||||
|
|
||||||
|
### 5. Rebuild
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo nixos-rebuild switch --flake .#<host>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify with `getent passwd <ldap-user>` and `id <ldap-user>`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
THIS IS A PLACEHOLDER, NOT A REAL AGE SECRET.
|
||||||
|
|
||||||
|
The real secrets/ldap-bind.age is produced by the repo owner with `agenix -e`
|
||||||
|
(see secrets/README.md) and is a binary age-encrypted blob. It is intentionally
|
||||||
|
NOT committed here because:
|
||||||
|
* the real host age recipients are not available to the author of this change
|
||||||
|
(they are each host's /etc/ssh/ssh_host_ed25519_key.pub), and
|
||||||
|
* fabricating an encrypted blob or fake host keys would be misleading.
|
||||||
|
|
||||||
|
Committing this file as `ldap-bind.age` would let modules/sssd.nix reference
|
||||||
|
`../secrets/ldap-bind.age` and evaluate, but SSSD would fail to decrypt it at
|
||||||
|
runtime. Do ONE of the following before deploying:
|
||||||
|
|
||||||
|
1. Preferred: generate the real secret (secrets/README.md), commit it as
|
||||||
|
secrets/ldap-bind.age, and delete this .PLACEHOLDER file.
|
||||||
|
|
||||||
|
The decrypted plaintext must be a valid sssd.conf drop-in (NOT the bare
|
||||||
|
password):
|
||||||
|
|
||||||
|
[domain/default]
|
||||||
|
ldap_default_authtok = <the sssd-bind service-account password>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
let
|
||||||
|
lyrathorpe-mbp = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_mbp";
|
||||||
|
lyrathorpe-t400 = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_t400";
|
||||||
|
lyrathorpe-macpro31 = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_macpro31";
|
||||||
|
lyrathorpe-rpi5 = "ssh-ed25519 AAAA_PLACEHOLDER_REPLACE_ME_rpi5";
|
||||||
|
sssdHosts = [
|
||||||
|
lyrathorpe-mbp
|
||||||
|
lyrathorpe-t400
|
||||||
|
lyrathorpe-macpro31
|
||||||
|
lyrathorpe-rpi5
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
"ldap-bind.age".publicKeys = sssdHosts;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user