LDAP
masks serves each tenant’s actors and groups as a read-only LDAP directory. LDAP is an older protocol that apps use to check passwords and look up people and groups.
Some apps sign people in only over LDAP, such as:
- NAS boxes
- Jellyfin
- Proxmox
- Gitea
- Wi-Fi and VPN gear that checks passwords through RADIUS, another protocol for checking passwords
These apps check passwords against masks and read who belongs to which group. They cannot add,
change, or remove anything. masks answers every write and compare with unwillingToPerform.
Turning it on
Section titled “Turning it on”The web server serves LDAP next to HTTP, on MASKS_LDAP_PORT. The default port is 3389. Until at
least one tenant turns the directory on, masks closes every connection at once.
To turn the directory on for a tenant:
- In
/manage, open Settings, then Directory. - Turn on Serve this tenant over LDAP.
Only a manager holding masks:manage can do this. Turning it off again refuses new binds, and stops
searches on connections that are already bound. A bind is how an LDAP client signs in to the
directory.
Publish the port so that apps can reach it:
services: masks: image: ghcr.io/masksrb/masks:latest environment: MASKS_LDAP_CERT: /certs/ldap.example.com.crt MASKS_LDAP_KEY: /certs/ldap.example.com.key ports: - "636:3389" volumes: - ./certs:/certs:roAn app that mounts masks as an engine serves LDAP once plugin :masks_ldap is in its
config/puma.rb. bin/rails generate masks:server:install adds that line.
Encryption
Section titled “Encryption”With MASKS_LDAP_CERT and MASKS_LDAP_KEY set, the listener speaks LDAPS on the same port. LDAPS is
LDAP over an encrypted connection. MASKS_LDAP_CERT is a PEM file that holds the certificate,
followed by any intermediates. See environment variables.
Without these two variables, the listener speaks plain LDAP and answers only on the server’s own
machine, at 127.0.0.1, so no password crosses the network as it was typed. A service on the same
machine can still bind. A service in another container cannot, since 127.0.0.1 inside the masks
container is the container itself. /manage says so under Directory while this is the case.
To serve plain LDAP on every network address anyway, set MASKS_LDAP_PLAINTEXT to true. Do this
only on a private network that you trust, since every password then crosses it in the clear.
StartTLS is not supported.
Limits
Section titled “Limits”- The listener closes a connection after five minutes without a request.
- It holds at most 256 connections at once.
- It accepts only LDAPv3 and simple binds.
The directory
Section titled “The directory”Each tenant is its own tree, named after its subdomain. A tenant called acme looks like this:
dc=acme,dc=masks├── ou=people│ ├── uid=alice,ou=people,dc=acme,dc=masks│ └── uid=bob,ou=people,dc=acme,dc=masks└── ou=groups └── cn=staff,ou=groups,dc=acme,dc=masksEach line is an entry. Its DN (distinguished name) is the full path that names it, such as
uid=alice,ou=people,dc=acme,dc=masks.
An actor is listed once their account is active. Suspended actors and actors waiting for approval are left out. Each actor has these attributes:
| Attribute | |
|---|---|
objectClass |
top, person, organizationalPerson, and inetOrgPerson. |
uid |
Their nickname. Their email address when they have no nickname. A nickname given up is held for 30 days, so a new account does not become the old uid right away. |
cn, displayName |
Their name. Their uid when they have no name. |
sn |
Their family name. cn when they have none. |
givenName |
Their given name, when they have one. |
mail |
Their email address, only once it is confirmed. |
memberOf |
The DN of each group they belong to. |
entryUUID |
Their account’s UUID. It never changes. |
Each group is a groupOfNames with these attributes:
| Attribute | |
|---|---|
cn |
The group’s key. |
description |
The group’s name. |
member |
The DN of each listed actor in the group. |
An entry never includes password digests, second factors, or secrets.
Connecting an app
Section titled “Connecting an app”An app uses two kinds of bind:
- It binds with an LDAP credential to search.
- It binds as the actor signing in, to check their password.
To give an app a credential:
- In
/manage, open Settings, then Directory. The credential form appears once the directory is on. - Enter a label, such as
jellyfin. - Under App, choose the client the app is registered as, when its people sign in with app passwords. Leave it as No app otherwise.
- Choose Issue a credential. Only a manager holding
masks:managecan issue one. - Copy the bind DN and the secret. The secret is shown once, and masks keeps only a digest of it.
The bind DN is
cn=<label>,ou=services,dc=<subdomain>,dc=masks. - Give the app the bind DN, the secret, and the Base DN shown on the same page.
A credential searches only its own tenant.
The list of credentials shows when each one last bound, to within five minutes. Revoke refuses a credential from then on, including on connections that are already open.
The manage API offers the same through issueLdapCredential, revokeLdapCredential, and
ldapCredentials. See the manage API reference.
Two other kinds of bind are limited:
- Anonymous binds succeed, but read only the root DSE. The root DSE is the entry that describes the server itself.
- A connection bound as an actor cannot search.
Password binds
Section titled “Password binds”An actor binds with their DN, such as uid=alice,ou=people,dc=acme,dc=masks, and their masks
password. When the server serves a single tenant, the uid alone also works.
LDAP cannot ask for a second factor. So masks refuses a password bind when:
- the account is suspended or waiting for approval
- the tenant’s default sign-in policy does not offer a password
- that policy requires a second factor, either always or for apps
- the actor has a second factor of their own, such as an authenticator app, a passkey, or codes by email or text message
Allow a password alone over LDAP, under Settings and then Directory, lifts the last two rules for the tenant. It is off by default. Turn it on only when every app that checks passwords over LDAP is worth less than the second factor it skips.
App passwords
Section titled “App passwords”An app password binds in place of the account password, for one app, with the second factor satisfied. It is the way to let one app in without lifting the rules for the whole tenant.
masks accepts it on a connection that first bound with a credential issued for that app’s client, under App on the credential form. That is how masks knows which app is asking. The bind is refused:
- through a credential issued with no app, or for another app
- on a direct bind with no credential before it
- when the client no longer accepts app passwords, or the app password is revoked
A successful bind records Signed in over LDAP with factor set to app_password.
Refusals and activity
Section titled “Refusals and activity”A wrong password, an unknown name, and every rule above answer invalidCredentials. A bind with a
name and an empty password answers unwillingToPerform.
masks records these events. Sign-in refused and Sign-ins throttled have via set to
ldap.
| Event | When |
|---|---|
| Sign-in refused | A wrong password, a rule above, or a wrong credential secret. Its factor names the reason. |
| Sign-ins throttled | An account reaches its attempt limit. |
| Signed in over LDAP | A password bind succeeds. It counts as activity for idle accounts. |
A DN that names no tenant, or no entry under ou=people or ou=services, records nothing.
Attempt limits
Section titled “Attempt limits”LDAP keeps its own count of failed binds, under the same limits as the sign-in page. After either
limit, within three minutes, masks answers unwillingToPerform without checking the password:
MASKS_ATTEMPT_LIMITfailures from one IP addressMASKS_ACCOUNT_ATTEMPT_LIMITfailures for one account
A wrong password, an unknown name, and a wrong credential secret count as failures. A refusal under the rules above does not count, and neither does a successful bind. So an app that checks many actors’ passwords from one IP address is not slowed down.
A wrong app password counts here and on the sign-in page both, so guessing over LDAP spends the account’s budget for the sign-in page too.
Searching
Section titled “Searching”Searches support:
- base, one-level, and subtree scopes
- filters built from equality, presence, substrings,
>=,<=,&(and),|(or), and!(not) ~=, which matches as equality
Attribute names and values match without regard to upper or lower case. A search returns at most its size limit, and never more than 10,000 entries.
masks answers other extended operations, StartTLS among them, with unwillingToPerform.
Check a credential with ldapsearch:
ldapsearch -H ldaps://ldap.example.com -x \ -D "cn=jellyfin,ou=services,dc=acme,dc=masks" -w "$SECRET" \ -b "dc=acme,dc=masks" "(&(objectClass=inetOrgPerson)(memberOf=cn=staff,ou=groups,dc=acme,dc=masks))" \ uid cn mailCheck an actor’s password with ldapwhoami. It prints their DN, or Invalid credentials (49):
ldapwhoami -H ldaps://ldap.example.com -x -D "uid=alice,ou=people,dc=acme,dc=masks" -WJellyfin
Section titled “Jellyfin”-
Install the LDAP Authentication plugin.
-
Fill in its settings:
Setting Value LDAP Server ldap.example.comLDAP Port The published port, such as 636Secure LDAP On, when the listener has a certificate Use StartTLS Off LDAP Bind User cn=jellyfin,ou=services,dc=acme,dc=masksLDAP Bind User Password The credential’s secret LDAP Base DN for searches ou=people,dc=acme,dc=masksLDAP Search Filter (memberOf=cn=jellyfin,ou=groups,dc=acme,dc=masks)LDAP Admin Filter (memberOf=cn=admins,ou=groups,dc=acme,dc=masks)LDAP Search Attributes uid, mailLDAP Uid Attribute uidLDAP Username Attribute uid
Actors in the jellyfin group then sign in with their masks nickname and password. Actors in the
admins group become Jellyfin administrators.
Proxmox
Section titled “Proxmox”Add an LDAP realm on any node, then sync it:
pveum realm add masks --type ldap \ --server1 ldap.example.com --port 636 --mode ldaps \ --base-dn ou=people,dc=acme,dc=masks --user-attr uid \ --bind-dn cn=proxmox,ou=services,dc=acme,dc=masks --password "$SECRET" \ --group-dn ou=groups,dc=acme,dc=masks --group-classes groupOfNames --group-name-attr cn \ --filter "(memberOf=cn=proxmox,ou=groups,dc=acme,dc=masks)"pveum realm sync masks --scope bothThe sync copies actors and groups into Proxmox. Grant permissions to the synced groups. Actors then
sign in as alice@masks with their masks password.
Other apps
Section titled “Other apps”Most apps that speak LDAP ask for the same few things:
| Asked for | Value |
|---|---|
| Server and port | The listener’s host and published port |
| Encryption | LDAPS, when the listener has a certificate |
| Bind DN and password | The credential’s bind DN and secret |
| User search base | ou=people,dc=<subdomain>,dc=masks |
| User filter | (&(objectClass=inetOrgPerson)(uid=%s)), with the app’s placeholder for the name typed |
| Username attribute | uid |
| Email attribute | mail |
| Group search base | ou=groups,dc=<subdomain>,dc=masks |
| Group filter | (objectClass=groupOfNames) |
| Group membership | member on the group, or memberOf on the actor |
- For Gitea, add an authentication source of type LDAP (via BindDN) with these values.
- Wi-Fi and VPN gear usually checks passwords through a RADIUS server, such as FreeRADIUS. Its
ldapmodule takes the same values.