Skip to content

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.

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:

  1. In /manage, open Settings, then Directory.
  2. 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:ro

An 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.

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.

  • 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.

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=masks

Each 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.

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:

  1. In /manage, open Settings, then Directory. The credential form appears once the directory is on.
  2. Enter a label, such as jellyfin.
  3. Under App, choose the client the app is registered as, when its people sign in with app passwords. Leave it as No app otherwise.
  4. Choose Issue a credential. Only a manager holding masks:manage can issue one.
  5. 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.
  6. 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.

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.

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.

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.

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_LIMIT failures from one IP address
  • MASKS_ACCOUNT_ATTEMPT_LIMIT failures 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.

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:

Terminal window
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 mail

Check an actor’s password with ldapwhoami. It prints their DN, or Invalid credentials (49):

Terminal window
ldapwhoami -H ldaps://ldap.example.com -x -D "uid=alice,ou=people,dc=acme,dc=masks" -W
  1. Install the LDAP Authentication plugin.

  2. Fill in its settings:

    Setting Value
    LDAP Server ldap.example.com
    LDAP Port The published port, such as 636
    Secure LDAP On, when the listener has a certificate
    Use StartTLS Off
    LDAP Bind User cn=jellyfin,ou=services,dc=acme,dc=masks
    LDAP Bind User Password The credential’s secret
    LDAP Base DN for searches ou=people,dc=acme,dc=masks
    LDAP 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, mail
    LDAP Uid Attribute uid
    LDAP 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.

Add an LDAP realm on any node, then sync it:

Terminal window
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 both

The sync copies actors and groups into Proxmox. Grant permissions to the synced groups. Actors then sign in as alice@masks with their masks password.

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 ldap module takes the same values.