Selfhosting multiple applications often means having to deal with disparate user accounts unless you can integrate them with a common third party. A tried and tested framework for this is the ubiquitous LDAP, or Lightweight Directory Access Protocol, server.
The LDAP server functions as a centralised identity and access management (IAM), meaning each application sees it as a single source of truth.
Motivation#
LDAP is a complex protocol with its own terminology and filter syntax. A common LDAP implementation is Microsoft’s Active Directory, or their cloud based Entra ID offering.
For homelab use you probably only need the bare minimum of the LDAP features. I’ve found the features offered by LLDAP to more than meet my needs. Other alternatives that LLDAP suggests themselves are OpenLDAP, FreeIPA and Kanidm.
LLDAP should integrate with most applications that support LDAP. The LLDAP repository supplies ample example integrations — including Dex, Gitea, Proxmox, Home Assistant, and Authelia.
Using a common identity provider (IdP) alleviates the need to individually maintain users in each application, making it easier to add new users and change passwords everywhere at once.
A more modern approach would be to use an OAuth/OIDC client — like Keycloak, Zitadel, Authelia, Authentik, or Kanidm, but not all applications natively support OAuth/OIDC. For applications that doesn’t support OAuth/OIDC there’s the option of using a middleware like OAuth2 Proxy or Traefik Forward Auth, but that’s an article for a later time.
Most of the aforementioned OAuth clients can act as a simple LDAP server themselves, and all except Kanidm can fetch users from an external LDAP-server, but in this article we’re keeping it simple with only LDAP with LLDAP.
My motivation for choosing LLDAP is that it’s simple and easy to get started, as well as the possibility to declaratively configure groups and users using a bootstrap script. As a nice bonus, LLDAP is written in Safe Rust, which in theory should make it resilient to buffer overflow and use-after-free attacks.
Overview#
Evantage-WS has already done a great job creating both a simple example Kubernetes deployment, and a Helm chart for LLDAP.
In this article we will use Kustomize to configure LLDAP and explore most configuration and integration options available for LLDAP.
After configuring and verifying a basic deployment of LLDAP, we will take a look at how we can bootstrap users and groups declaratively, before configuring SMTP for password resetting. Next we’ll enable Secure LDAP (LDAPS) with the help of Cert manager, and lastly we’ll integrate with an external database to enable high availability.
This article assumes you have your own domain at <DOMAIN>.<TLD>
which will be used as an example throughout the
article.
If you’re not one for details you can skip to the Summary-section for the final manifests or visit the repository for this article.
Basic Configuration#
We’ll leverage Kustomize’s ability to reference the different manifests required to configure LLDAP in order to organise everything
|
|
LLDAP can be configured using either
a configuration file or environment
variables.
To save some lines of YAML we’ll take advantage of
envFrom
to map ConfigMap data to container environment
variables.
We can utilise Kustomize’s built-in
configMapGenerator to
automatically generate a ConfigMap that holds some of
the LLDAP configuration environment variables.
Most important is the LLDAP_LDAP_BASE_DN
-variable (line 12) which should be configured to your own domain.
Next, we declaratively define the Namespace for completeness’ sake
|
|
and prepare a Service referencing named ports (line 14, 17) that we’ll define shortly
|
|
We also need to define cryptographic values preferably stored in a
Secret-resource to hold a secure random
value designated LLDAP_JWT_SECRET
— used to sign the JWTs issued by LLDAP,
and a seed called LLDAP_KEY_SEED
— used to hash passwords stored in the database.
The LLDAP configuration suggests to run
LC_ALL=C tr -dc 'A-Za-z0-9!#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
in order to generate a random enough value for the JWT secret and at least 12 random characters for the hash seed. Assuming you don’t encrypt your secret using e.g. Sealed Secrets, or run an implementation of Secrets Store CSI Driver, the Secret should look something like
|
|
Note that it’s possible — though computationally prohibitive, to brute force user passwords if you know the key seed and have access to a database dump.
Lastly, we also need to define an admin user for our LLDAP instance
|
|
With the auxiliary config created, we’re ready to design a Deployment of LLDAP that uses all the ConfigMaps and Secrets we just defined (line 22, 24, 26)
Note that we’ve picked the rootless version of LLDAP (line 19) for some extra security. An even more hardened deployment can be found in the Summary-section.
|
|
We’ve also named port 3890 and 17170 which are, respectively, the default LDAP and web ports
(line 28, 30).
These can be changed with the LLDAP_LDAP_PORT
and LLDAP_HTTP_PORT
environment variables.
By defining everything declaratively — see the Bootstrapping-section, we don’t need to configure additional persistence, and can thus use an emptyDir-volume for storing the LLDAP-data (line 33).
If you on the other hand want to store the state you have to define a PersistentVolume with an associated PersistentVolumeClaim. For easier PersistentVolume provisioning you can take a look at Container Storage Interface (CSI) implementations like the Proxmox CSI Plugin.
Another option for persistence is using an external database, something we’ll explore in the aptly named External Database-section later.
Applying the manifests by running
kubectl apply -k .
in the folder containing the kustomization.yaml-file should then deploy LLDAP.
Testing#
Assuming the deployment went OK, we can now test our LDAP server.
In order to do this we first need to install some tools. Depending on you OS/package manager, these tools are bundled in either the openldap (brew), ldap-utils (apt), or openldap-clients (dnf, yum, apk).
With the tools installed we need to create a route to our LDAP-server which can be done by port-forwarding the service we just created by running1
kubectl port-forward service/lldap 3890:389 -n lldap
If everything is working as expected you should now be able to invoke
ldapsearch -H ldap://localhost:3890 \
-D "uid=<USER>,ou=people,dc=<DOMAIN>,dc=<TLD>" \
-w "<PASSWORD>" \
-b "dc=<DOMAIN>,dc=<TLD>" "(objectClass=*)"
to list all objects stored in LLDAP.
Some LDAP-operations are not supported by LLDAP, e.g.
ldapwhoami -H ldap://localhost:3890 \
-D "uid=<USER>,ou=people,dc=<DOMAIN>,dc=<TLD>" \
-w "<PASSWORD>"
which will result in the following message
ldap_parse_result: Server is unwilling to perform (53)
additional info: Unsupported extended operation: 1.3.6.1.4.1.4203.1.11.3
Result: Server is unwilling to perform (53)
Additional info: Unsupported extended operation: 1.3.6.1.4.1.4203.1.11.3
I assume this means that the functionality isn’t implemented in LLDAP.
Bootstrapping (Optional)#
The default configuration for LLDAP only allows declaration of an admin user, but they graciously supply a bootstrap-option that lets us configure as many users, groups, and custom attributes (schemas) we want2
LDAP objects to be bootstrapped are defined as a JSON-object, e.g. a user can be defined as
|
|
where only the id
and email
fields are mandatory.
It’s possible to define multiple users in a single file, though not as a JSON-list, but as consecutive JSON-objects, e.g.
|
|
Note that it’s possible to omit the password, in which case you need to configure password reset — which we cover in the next section, in order to set an initial password to be able to log in.
Groups are defined in a similar fashion as users
|
|
Check the official documentation for how to configure custom schemas.
We can easily use Kustomize’s configMapGenerator to create ConfigMaps of the configuration files (line 9-11) to mount them into a Pod
|
|
The bootstrap script also needs the http-url of LLDAP (line 15) which will be the service name assuming you run the bootstrapping Job in the same Namespace.
Setting the DO_CLEANUP
variable to true (line 16) will
“delete groups and users not specified in config files, also remove users from groups that they do not belong to”.
If you’re using Argo CD you can add the annotations on line 8 and 9 in order to relaunch the job on every sync. Read the Argo CD documentation for more information about resource hooks.
We can reuse the same image as the main deployment (line 15) which ship with the bootstrap.sh
-script,
we just have to change the start-command as on line 16.
The bootstrap environment variables are injected from the bootstrap-env
-ConfigMap (line 19),
as well as mapping the admin credentials (line 21 and 26) from the lldap-credentials
-Secret (line 24 and 29).
|
|
Next we create projected volumes (line 43 and 53) from
the bootstrap-config
-ConfigMap (line 47 and 57) containing the JSON-files we previously wrote,
and mount them at the correct locations (line 34 and 37).
SMTP — Password Reset (Optional)#
If you plan on hosting multiple personal accounts in your LLDAP instance it’s a good idea to let them reset their passwords themselves. In order to enable this we need some kind of SMTP-integration for LLDAP to be able to send password reset e-mails.
It’s possible to self-host a simple solution like docker-postfix, or something more complex like mailcow, Mox, or Dovecot, but setting up one of those are all articles in themselves.
I haphazardly picked SendGrid which I found easy to integrate with LLDAP. Take a look at this Hacker News thread for more options.
It should be fairly straight forward to create a free account with SendGrid and verify it with your domain under Settings > Sender Authentication using DNS entries.
You can create a new sender e-mail, e.g. no-reply@<DOMAIN>.<TLD>
under Marketing > Senders and find a guide on
how to integrate it with LLDAP under Email API > Integration Guide shown in the picture below
Choose the SMTP Relay option and take note of the API key you create.
Also make sure to disable tracking under Settings > Tracking to avoid overwriting links and adding images to track opened e-mails.
With your API-key (line 13) and e-mail address (line 14) in hand, create the following Secret
|
|
and inject the environment variables in the LLDAP Deployment by adding
envFrom:
- secretRef:
name: smtp-settings
to the LLDAP-container.
We also need to configure the LLDAP base URL to be used in the password reset e-mail which is done with the
LLDAP_HTTP_URL
environment variable which can be done using Kustomize’s configMapGenerator
configMapGenerator:
- name: lldap-config
namespace: lldap
literals:
- LLDAP_HTTP_URL="https://lldap.<DOMAIN>.<TLD>"
LDAPS (Optional)#
LDAPS is LDAP over TLS/SSL, and is highly recommended if you plan to expose your LDAP-server to the internet — though exposing LDAP to the internet is not advisable, or want an extra layer of security for inter-container communication.
Similar to HTTPS we need an X.509 certificate to enable the secure part. The easiest way to obtain such a certificate is by using Cert manager. I’ve already explained how to get started with Cert manager in my Traefik wildcard certificates article, so I’ll allow myself to be brief here.
Assuming you’ve configured a ClusterIssuer imaginatively called
cluster-issuer
(line 13),
you can create the following Certificate-resource
|
|
to issue a certificate for you chosen domain main (line 9) as
a TLS Secret called cert
(line 14).
We can then create a volume in our LLDAP Deployment with this secret
volumes:
- name: cert
secret:
secretName: cert
items:
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
and mount the volume in the LLDAP-container
volumeMounts:
- name: cert
mountPath: /cert
Next we can configure LLDAP to enable LDAPS and use the certificate
configMapGenerator:
- name: lldap-config
namespace: lldap
literals:
- LLDAP_LDAPS_OPTIONS__ENABLED="true"
- LLDAP_LDAPS_OPTIONS__CERT_FILE="/cert/tls.crt"
- LLDAP_LDAPS_OPTIONS__KEY_FILE="/cert/tls.key"
For TLS-communication to work inside the cluster with public certificate,
i.e. not self-signed certificates for the cluster.local
domain
— which we in that case have to distribute,
we can create DNS-entries for our LLDAP-service.
We start by assigning a static clusterIP
(line 9) to the LLDAP-service.
To minimise the possibility of a Service Cluster IP conflict
we can pick an IP in the lower band of the Service IP range, e.g. 10.96.0.15
|
|
We also need to route to the LDAPS-port (line 19-21) by adding
ports:
- name: ldaps
containerPort: 6360
to the LLDAP container.
You can now test that everything works by port-forwarding the ldaps-port
kubectl port-forward service/lldap 6360:636 -n lldap
and try to get the certificate information by running
openssl s_client -connect localhost:6360
If this works you can add an entry in your /etc/hosts
-file
# /etc/hosts
127.0.0.1 lldap.<DOMAIN>.<TLD>
to route lldap.<DOMAIN>.<TLD>
to our port-forward in order for the certificate to be valid.
If the following search returns OK everything should be configured properly
ldapsearch -H ldaps://lldap.<DOMAIN>.<TLD>:6360 \
-D "uid=<USER>,ou=people,dc=<DOMAIN>,dc=<TLD>" \
-w "<PASSWORD>" \
-b "dc=stonegarden,dc=dev" "(objectClass=*)"
Clean up by stopping the port-forward and remove the /etc/hosts
entry.
We can now create a rule in CoreDNS to forward all
internal requests to lldap.<DOMAIN>.<TLD>
to 10.96.0.15
by editing the Corefile-config by running
kubectl edit configmap coredns -n kube-system
and adding the following hosts
block in the Corefile-config
data:
Corefile: |-
.:53 {
...
hosts {
10.96.0.15 lldap.<DOMAIN>.<TLD>
fallthrough
}
...
}
Restart the CoreDNS pods to apply the configuration changes
kubectl rollout restart deployment coredns -n kube-system
If you can’t — or don’t want to, edit the CoreDNS config you can instead add a host alias to the Pods that should talk ldaps with LLDAP, e.g.
apiVersion: apps/v1
kind: Deployment
metadata:
name: <DEPLOYMENT_NAME>
spec:
template:
spec:
hostAliases:
- ip: 10.96.0.15
hostnames:
- lldap.<DOMAIN>.<TLD>
External Database (Optional)#
LLDAP ships with a built-in SQLite database to store its state. For most homelab uses this should be more than enough, but if you want high availability you need to use an external database in order to be able to spin up multiple instances of LLDAP.
LLDAP supports both MySQL and PostgreSQL. I’ve previously written about PostgreSQL on Kubernetes where I decided to go with CloudNative PG (CNPG) to spin up PostgreSQL clusters inside Kubernetes clusters. Other alternatives are Zalando Postgres Operator, Crunchy Data, KubebBocks, Percona Everest, Stolon, and StackGres.
Assuming you’ve configured a default StorageClass and CNPG, you can create a database cluster for LLDAP with the following resource
|
|
With the above manifest, CNPG creates a secret called lldap-postgres-app
with a connection uri on the form
postgresql://<USER>:<PASSWORD>@<URI>:<PORT>/<DATABASE>
which we can map to the LLDAP_DATABASE_URL
environment
variable in the LLDAP container
env:
- name: LLDAP_DATABASE_URL
valueFrom:
secretKeyRef:
name: lldap-postgres-app
key: uri
If you already have a database you want to connect with you can instead create a Secret with the correct database uri.
LLDAP provides a migration guide if you start with SQLite and decide to change to an external database at a later stage.
Summary#
All resources can be found in this GitLab repo, and are written for use with Argo CD with Kustomize + Helm, but should be easily adaptable for other approaches using e.g. Flux CD.
# lldap/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
configMapGenerator:
- name: lldap-config
namespace: lldap
literals:
- TZ="Europe/Oslo"
- GID="1001"
- UID="1001"
- LLDAP_LDAP_BASE_DN="dc=<DOMAIN>,dc=<TLD>"
- LLDAP_HTTP_URL="https://lldap.<DOMAIN>.<TLD>"
- LLDAP_LDAPS_OPTIONS__ENABLED="true"
- LLDAP_LDAPS_OPTIONS__CERT_FILE="/cert/tls.crt"
- LLDAP_LDAPS_OPTIONS__KEY_FILE="/cert/tls.key"
- name: bootstrap-config
namespace: lldap
files:
- users/user.json
- users/more-users.json
- groups/groups.json
- name: custom-schemas
namespace: lldap
files:
- group-schemas/group-application.json
- user-schemas/user-details.json
- name: bootstrap-env
namespace: lldap
literals:
- LLDAP_URL="http://lldap:80"
- DO_CLEANUP="true"
resources:
- ns.yaml
- svc.yaml
- crypto.yaml
- admin-credentials.yaml
- smtp-settings.yaml
- cert.yaml
- cnpg-db.yaml
- deployment.yaml
- bootstrap.yaml
# lldap/ns.yaml
apiVersion: v1
kind: Namespace
metadata:
name: lldap
# lldap/svc.yaml
apiVersion: v1
kind: Service
metadata:
name: lldap
namespace: lldap
spec:
type: ClusterIP
clusterIP: 10.96.0.15
selector:
app: lldap
ports:
- name: http
port: 80
targetPort: http
- name: ldap
port: 389
targetPort: ldap
- name: ldaps
port: 636
targetPort: ldaps
# lldap/crypto.yaml
apiVersion: v1
kind: Secret
metadata:
name: crypto
namespace: lldap
stringData:
LLDAP_KEY_SEED: "<RANDOM_SEED>"
LLDAP_JWT_SECRET: "<RANDOM_SECRET>"
# lldap/admin-credentials.yaml
apiVersion: v1
kind: Secret
metadata:
name: admin-credentials
namespace: lldap
stringData:
LLDAP_LDAP_USER_DN: "admin"
LLDAP_LDAP_USER_PASS: "<RANDOM_PASSWORD>"
# lldap/smtp-settings.yaml
apiVersion: v1
kind: Secret
metadata:
name: smtp-settings
namespace: lldap
stringData:
LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET: "true"
LLDAP_SMTP_OPTIONS__SERVER: "smtp.sendgrid.net"
LLDAP_SMTP_OPTIONS__PORT: "587"
LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION: "STARTTLS"
LLDAP_SMTP_OPTIONS__USER: "apikey"
LLDAP_SMTP_OPTIONS__PASSWORD: "<API_KEY>"
LLDAP_SMTP_OPTIONS__FROM: "LLDAP <no-reply@<DOMAIN>.<TLD>>"
# lldap/cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: cert
namespace: lldap
spec:
dnsNames:
- lldap.<DOMAIN>.<TLD>
issuerRef:
group: cert-manager.io
kind: ClusterIssuer
name: cluster-issuer
secretName: cert
usages:
- digital signature
- key encipherment
# lldap/cnpg-db.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: lldap-postgres
namespace: lldap
spec:
instances: 3
bootstrap:
initdb:
database: lldap
owner: lldap
storage:
size: 2G
# lldap/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: lldap
namespace: lldap
spec:
replicas: 3
selector:
matchLabels:
app: lldap
template:
metadata:
namespace: lldap
labels:
app: lldap
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: lldap
image: docker.io/lldap/lldap:v0.6.1-alpine-rootless
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ ALL ]
envFrom:
- configMapRef:
name: lldap-config
- secretRef:
name: crypto
- secretRef:
name: admin-credentials
- secretRef:
name: smtp-config
env:
- name: LLDAP_DATABASE_URL
valueFrom:
secretKeyRef:
name: lldap-postgres-app
key: uri
ports:
- name: ldap
containerPort: 3890
- name: ldaps
containerPort: 6360
- name: web
containerPort: 17170
volumeMounts:
- name: lldap-data
mountPath: /data
- name: cert
mountPath: /cert
volumes:
- name: cert
secret:
secretName: cert
items:
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
- name: lldap-data
emptyDir: { }
# lldap/bootstrap.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: lldap-bootstrap
namespace: lldap
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
template:
spec:
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: lldap-bootstrap
image: ghcr.io/lldap/lldap:v0.6.1-alpine-rootless
command: [ /app/bootstrap.sh ]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ ALL ]
envFrom:
- configMapRef:
name: bootstrap-env
env:
- name: LLDAP_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: lldap-credentials
key: LLDAP_LDAP_USER_DN
- name: LLDAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: lldap-credentials
key: LLDAP_LDAP_USER_PASS
volumeMounts:
- name: tmp
mountPath: /tmp
- name: user-schemas
mountPath: /bootstrap/user-schemas
readOnly: true
- name: group-schemas
mountPath: /bootstrap/group-schemas
readOnly: true
- name: groups
mountPath: /bootstrap/group-configs
readOnly: true
- name: users
mountPath: /bootstrap/user-configs
readOnly: true
volumes:
- name: tmp
emptyDir: { }
- name: group-schemas
projected:
sources:
- configMap:
name: custom-schemas
items:
- key: group-application.json
path: group-application.json
- name: user-schemas
projected:
sources:
- configMap:
name: custom-schemas
items:
- key: user-details.json
path: user-details.json
- name: groups
projected:
sources:
- configMap:
name: bootstrap-config
items:
- key: groups.json
path: groups.json
- name: users
projected:
sources:
- configMap:
name: bootstrap-config
items:
- key: user.json
path: user.json
- key: more-users.json
path: more-users.json
[
{
"name": "application",
"attributeType": "STRING",
"isEditable": false,
"isList": false,
"isVisible": true
}
]
[
{
"name": "favourite number",
"attributeType": "INTEGER",
"isEditable": true,
"isList": false,
"isVisible": true
},
{
"name": "nickname",
"attributeType": "STRING",
"isEditable": false,
"isList": false,
"isVisible": true
},
{
"name": "notes",
"attributeType": "STRING",
"isEditable": false,
"isList": true,
"isVisible": true
}
]
{
"name": "argocd:admin"
}
{
"name": "argocd:read_all"
}
{
"id": "username",
"email": "[email protected]",
"password": "changeme",
"displayName": "Display Name",
"firstName": "First",
"lastName": "Last",
"avatar_file": "/path/to/avatar.jpg",
"avatar_url": "https://i.imgur.com/nbCxk3z.jpg",
"gravatar_avatar": "false",
"weserv_avatar": "false",
"groups": [
"group-1",
"group-2"
]
}
{
"id": "spike",
"email": "[email protected]",
"firstName": "Spike",
"lastName": "Spiegel",
"password": "fearless123"
}
{
"id": "faye",
"email": "[email protected]",
"firstName": "Faye",
"lastName": "Valentine"
}
{
"id": "ed",
"email": "[email protected]",
"firstName": "Françoise",
"lastName": "Lütfen",
"displayName": "Edward Wong Hau Pepelu Tivrusky IV",
"groups": [
"argocd:admin"
]
}