A small, opinionated web UI to administer users and groups in an existing,
external LDAP server — OpenBSD ldapd(8)
(the original target) or OpenLDAP (directory = "openldap").
weft does not embed a directory; it is a thin, single-binary admin front-end
that authenticates through the LDAP itself (passthrough bind).
The UI is bilingual (German/English, switchable at runtime); the code, identifiers and API are English.
- Single static binary. The Svelte SPA is built and embedded via
embed.FS; there is no Node runtime at runtime. Cross-compiles cleanly toopenbsd/amd64. - Minimal config. At its core just
ldap_url+base_dn+ a TLS mode. - No service account. Every session re-binds as the logged-in user; the directory's own ACLs do the enforcing.
- Opinionated structure.
ou=people(RDNuid),ou=groups(posixGroup only), a shared default primary group, bcrypt{CRYPT}passwords. - Bilingual UI. German/English, toggled in the header (DE/EN) and remembered per browser; defaults to the browser language.
- Paginated user list. LDAP has no offset-based pagination, so weft fetches and sorts the full (search-)filtered result set server-side and pages it from there; a persistent "N users total" count stays visible independent of the current search or page.
- Bulk import. Upload a CSV, Excel (.xlsx) or Apple Numbers user list, map
its columns, review/edit every row in the browser, then create the users.
Files are parsed client-side; missing passwords are generated (see
Security) and offered once as a CSV download (never stored). An
optional test-user generator (
enable_test_user_generator, off by default) adds a second entry point that creates a block of synthetic users instead of reading a file, optionally with one uniform password for the whole batch — for load-testing/demo data. Generated rows may share a mail address (several test logins for one real developer/tester); the reviewer only treats a duplicate mail as an error for actual file imports. - Configurable attributes.
[[user_attr]]entries inweft.tomladd further single-valued attributes (telephoneNumber, ou, st, …) to the forms, the API and the import — e.g. everything a Matrix user-directory sync reads. - Process sandboxing. After reading its files, when started as root weft
chroot(2)s and drops privileges to_weft(Linux, macOS, FreeBSD, the BSDs); on OpenBSD it additionally appliespledge(2)/unveil(2). - Operable.
GET /api/healthzanswers200/503depending on whether the directory is reachable (the Docker image has a matchingHEALTHCHECK), andlog_levelturns the access log off (warn) or adds a line per LDAP operation (debug) when a directory problem needs diagnosing.
ldapd's ACLs cannot express group membership — a rule's subject is only
by any, by self, or by <single dn>. weft therefore uses the simplest model
ldapd can enforce honestly:
- Admin = the ldapd
rootdn. You log in by typing the admin uid (admin_uid, defaultadmin); the session then binds as the admin DN, which must equal ldapd'srootdn. The bind DN isadmin_dnif set, otherwise the deriveduid=<admin_uid>,ou=people,<base>. weft prints the resolved admin bind DN at startup and shows it in the setup wizard so you can match it torootdn. The rootdn bypasses ACLs, so it can create/modify everything; it is synthetic and need not exist as an entry. "Change password" in the admin session writesuserPasswordonadmin_dnitself. That works whenadmin_dnis a real entry (on OpenLDAP, e.g. a dedicated admin account granted rights viaolcAccess); a synthetic rootdn's password lives inldapd.conf(rootpw) orolcRootPWand weft says so instead of changing it. To hide the option altogether, setadmin_can_change_own_password = false. - Everyone else = self-service only. They may view their own profile/groups
and change their own password (
by selfwrite, restricted touserPassword).
weft applies the same model to OpenLDAP: one admin DN (the rootdn),
self-service for everyone else. OpenLDAP could express more (group-based ACLs,
delegated admins), but weft deliberately keeps one authorization model across
both servers.
Set the flavor and, typically, the rootdn shape:
directory = "openldap"
admin_dn = "cn=admin,dc=example,dc=org" # must equal olcRootDNDifferences from ldapd that weft handles for you:
| ldapd | OpenLDAP | |
|---|---|---|
| uid rename | add-new → fixup memberUid → delete-old (not atomic) |
ModifyDN (atomic for the entry) → fixup memberUid |
| suffix entry | created by the setup wizard | usually pre-created; the wizard tolerates “already exists” |
| rootdn | rootdn/rootpw in ldapd.conf |
olcRootDN/olcRootPW in cn=config |
The passwords weft writes ({CRYPT}$2b$… = bcrypt) verify on bind if slapd's
libcrypt supports bcrypt (standard on OpenBSD; on most Linux distributions
glibc's crypt supports $2b$; verify with a test bind).
ACLs matching the weft model (olcAccess on the database, order matters):
olcAccess: {0}to attrs=userPassword
by self =w
by anonymous auth
by * none
olcAccess: {1}to *
by self read
by users read
by * none
The rootdn bypasses ACLs and needs no rule. {1} grants authenticated users
read (needed for the UI's own-profile and group views); tighten by users read
to by self read if users must not see each other. Anonymous needs nothing
beyond auth on userPassword: weft performs no unauthenticated reads, not
even for the setup wizard (see First-run setup).
dc=example,dc=org
├── ou=people users, RDN uid
│ └── uid=alice inetOrgPerson (+ posixAccount, + mail — optional)
└── ou=groups groups, posixGroup only
└── cn=users the default primary group (gidNumber on each user)
- User =
inetOrgPersonbase, with optional POSIX (posixAccount: uidNumber/gidNumber/homeDirectory/loginShell/gecos — required for shell accounts) and optional Mail (mail+ aliases) profiles. - Group =
posixGroupwithcn,gidNumber,memberUid(uid-based, may be empty). Primary group is the user'sgidNumber; supplementary groups arememberUidentries. weft merges both for the effective view. - uid/gid numbers are auto-allocated (smallest free in the configured range, serialised by an app-side lock) and can be overridden by the admin.
Some directories (notably Matrix/Synapse LDAP setups, where
matrix-synapse-ldap3's
attributes.uid config key commonly maps to cn) name user entries
cn=<value>,ou=people,<base> instead of uid=<value>,.... Set
user_id_attr = "cn" in weft.toml and weft uses cn as the RDN, login name,
and memberUid value everywhere instead of uid.
The identifier keeps the same strict charset regardless of which attribute
backs it (see service.ValidName): Synapse's own registration check rejects
localparts with spaces, uppercase, or non-ASCII characters outright (HTTP 400
INVALID_USERNAME), so cn must hold a short login handle (jdoe) here, not
a full display name ("Jane Doe") — givenName/sn/displayName remain free
text for that. Because LDAP requires an entry's RDN value to be one of its own
attribute's values, cn can't independently hold a different display string
in this mode: weft's user form has no separate "cn" field when
user_id_attr = "cn", and the API forces cn to equal the identifier on
write regardless of what's submitted.
These shaped the design (checked against the ldapd source and man pages):
| Fact | Consequence in weft |
|---|---|
Ships core, inetorgperson, nis schemas and enforces loaded schema |
Entries carry the full objectClass chain and all MUST attributes |
| No ModifyDN/ModRDN operation | uid rename is done as add-new → fix memberUid → delete-old (not atomic) |
Bind verifies {CRYPT} (= bcrypt on OpenBSD), {SHA}, {SSHA} |
weft writes userPassword: {CRYPT}$2b$...; it never verifies hashes itself and reads them only to copy them verbatim in a uid rename |
ACL subjects are only any / self / a single DN |
Admin = rootdn; users limited to by self userPassword writes |
| The namespace suffix entry is not auto-created | the setup wizard creates the base entry (dc=/o=/ou=) before the OUs, so the directory may start empty |
Requires Go (see the version in go.mod) and Node ≥ 20 — both only on
the build host. The resulting binary has no runtime dependencies.
make build # builds the SPA, then the host binary -> ./weft
make build-openbsd # cross-compiles a static binary -> ./weft.openbsd-amd64
make test # Go tests (no LDAP needed; uses the in-memory Fake)make web builds just the frontend into web/dist (embedded by web/embed.go).
The frontend must be built before the Go binary, because the SPA is embedded via
embed.FS — the make targets handle that ordering for you. A placeholder
web/dist/index.html is committed so go test/go vet work without Node.
The whole point is to develop on any platform and ship one static binary to OpenBSD. The easiest way:
make build-openbsd # -> ./weft.openbsd-amd64That target first builds the SPA, then runs, in effect:
GOOS=openbsd GOARCH=amd64 CGO_ENABLED=0 \
go build -trimpath -ldflags '-s -w -X main.version=<version>' \
-o weft.openbsd-amd64 ./cmd/weftNotes:
CGO_ENABLED=0keeps it pure Go — no libc linkage, no cross C toolchain needed. The binary is self-contained (it still uses OpenBSD'sld.so, which is always present).<version>comes fromgit describevia the Makefile (e.g.v0.1.0); it is reported byweft -version.- For the arm64 port, set
GOARCH=arm64instead. - No Go, Node or other tooling is required on the OpenBSD target — only the binary and a config file.
Copy the artifact over and install it (see Deploy on OpenBSD):
scp weft.openbsd-amd64 you@host:/tmp/weft
# on the host:
doas install -o root -g bin -m 0555 /tmp/weft /usr/local/bin/weftCopy weft.toml.example to weft.toml and edit. Then:
./weft -config weft.tomlConfig precedence: defaults < TOML file < WEFT_* env vars < flags. Flags:
-config, -listen, -insecure (skip LDAP TLS certificate verification —
prefer ca_cert_file), -log-level, -dev, -dev-rootpw, -version.
Everything weft reads, with its built-in default. Options with no env variable
are file-only — mount a TOML and pass -config (that is what the Docker
examples do for the [[user_attr]] tables).
| Option | Default | Env / flag | What it does |
|---|---|---|---|
| Core | |||
ldap_url |
— (required) | WEFT_LDAP_URL |
ldaps://, ldap:// or ldapi:///path/to/socket |
base_dn |
— (required) | WEFT_BASE_DN |
namespace suffix, e.g. dc=example,dc=org |
directory |
ldapd |
WEFT_DIRECTORY |
server flavor: ldapd or openldap |
user_id_attr |
uid |
WEFT_USER_ID_ATTR |
naming attribute for users: uid or cn |
| Transport security to the LDAP server | |||
tls_mode |
ldaps |
WEFT_TLS_MODE |
ldaps, starttls or plain; ignored for ldapi:// |
ca_cert_file |
system trust store | WEFT_CA_CERT_FILE |
CA that signed the LDAP server certificate |
insecure_skip_verify |
false |
WEFT_INSECURE_SKIP_VERIFY, -insecure |
accept any LDAP certificate (warns at startup) |
allow_plain_bind |
false |
WEFT_ALLOW_PLAIN_BIND |
required opt-in for tls_mode = "plain" |
| Admin identity | |||
admin_uid |
admin |
WEFT_ADMIN_UID |
the login name that means "admin" |
admin_dn |
<user_id_attr>=<admin_uid>,ou=<people_ou>,<base_dn> |
WEFT_ADMIN_DN |
the bind DN — must equal the server's rootdn |
allow_admin |
true |
WEFT_ALLOW_ADMIN |
false = self-service only, no admin login, no wizard |
admin_can_change_own_password |
true |
WEFT_ADMIN_CAN_CHANGE_OWN_PASSWORD |
false = no "change password" in the admin session (e.g. when the admin is the rootdn) |
| Directory layout | |||
people_ou |
people |
— | OU holding user entries |
groups_ou |
groups |
— | OU holding group entries |
primary_group |
users |
— | default primary group created by the wizard |
| POSIX defaults | |||
uid_min / uid_max |
10000 / 59999 |
— | allocation range for uidNumber |
gid_min / gid_max |
10000 / 59999 |
— | allocation range for gidNumber |
home_template |
/home/{uid} |
— | {uid} is replaced by the login name |
default_shell |
/bin/ksh |
— | loginShell for new POSIX profiles |
mail_attr |
mail |
— | attribute holding the primary address |
mail_alias_attr |
(empty) | — | attribute for aliases; empty = extra mail_attr values |
| Extra user attributes | |||
[[user_attr]] |
— | — | table per attribute: attr, label_de, label_en, required, optional [[user_attr.options]] (value, label_de, label_en) for a fixed value set |
user_extra_classes |
— | — | auxiliary objectClasses needed by those attributes |
enable_test_user_generator |
false |
WEFT_TEST_USER_GENERATOR |
offer synthetic test users in the import wizard |
| Passwords | |||
bcrypt_cost |
12 |
WEFT_BCRYPT_COST |
cost of the {CRYPT}$2b$ hash weft writes |
max_password_length |
72 |
— | bcrypt truncates beyond 72 bytes |
| Sandbox (Unix; no-op elsewhere) | |||
sandbox |
true |
WEFT_SANDBOX |
master switch for pledge/unveil + privsep confinement |
chroot |
/var/empty |
WEFT_CHROOT |
worker chroot when started as root; empty disables |
user / group |
_weft / (user's primary) |
WEFT_USER / WEFT_GROUP |
identity the worker drops to |
| Logging | |||
log |
stderr |
WEFT_LOG |
destination: stderr or syslog |
log_level |
info |
WEFT_LOG_LEVEL, -log-level |
debug, info, warn or error |
syslog_tag |
weft |
WEFT_SYSLOG_TAG |
program tag, facility LOG_DAEMON |
| HTTP server | |||
listen_addr |
127.0.0.1:8080 |
WEFT_LISTEN_ADDR, -listen |
address weft serves on |
tls_cert_file / tls_key_file |
— | WEFT_TLS_CERT_FILE / WEFT_TLS_KEY_FILE |
serve HTTPS directly instead of behind a proxy |
session_timeout |
30m |
WEFT_SESSION_TIMEOUT |
idle expiry, sliding; drives the SPA's auto-logout |
cookie_secure |
true |
WEFT_COOKIE_SECURE |
false only for local plain-HTTP dev |
trusted_proxies |
(empty) | WEFT_TRUSTED_PROXIES (comma-separated) |
CIDRs or IPs of reverse proxies whose X-Forwarded-For is believed for the login rate limit; empty = ignore the header |
-dev runs against an in-memory fake directory with no LDAP server at all
(admin password from -dev-rootpw, default rootpw); it is for development,
never for a deployment.
For a same-host deployment, point ldap_url at ldapd's Unix socket —
ldap_url = "ldapi:///var/run/ldapi" (with listen on "/var/run/ldapi" in
ldapd.conf). With an ldapi:// url the connection is local and secured by
filesystem permissions, so tls_mode, ca_cert_file, insecure_skip_verify
and allow_plain_bind are all ignored — no TLS or certificates needed.
At startup weft logs the LDAP server URL, the resolved admin bind DN, and (when
enabled) warnings for insecure_skip_verify / tls_mode=plain (suppressed for
ldapi://). Every HTTP
request is logged as METHOD /path -> status (duration) (never bodies or
credentials); unknown /api paths return a JSON {"error":"unbekannter API-Endpunkt: …"} so they are distinguishable from a proxy's 404.
Log in as the admin uid (e.g. admin) with the rootpw — that works on a
completely empty directory, since the rootdn is synthetic and needs no entry.
weft then checks on that authenticated connection whether ou=people exists;
if it does not, the session opens the setup wizard instead of the app. One
click creates the base/suffix entry, ou=people, ou=groups and the default
users group — no second password prompt, the session already holds it. The
wizard is idempotent, so it is safe to re-run.
weft never reads the directory anonymously: before login it only checks that
the server accepts a connection. (Up to 0.2.0 the wizard probed ou=people
anonymously, which made servers that deny anonymous access — the ACLs
recommended above among them — look permanently unprovisioned, so the wizard
ran again after every successful bootstrap.) Note that allow_admin = false
therefore also disables the wizard; provision such a directory before turning
admin login off.
The admin bind DN that weft uses (logged at startup, shown in the wizard) must
equal ldapd's rootdn. If your rootdn is not uid=<admin_uid>,ou=people,<base>,
set admin_dn in weft.toml to match it exactly.
./weft -dev -listen 127.0.0.1:8099 # in-memory fake directory, no LDAP
cd web && npm run dev # Vite dev server, proxies /api to :8099In -dev mode the admin is admin / rootpw (override with -dev-rootpw).
A multi-stage Dockerfile builds the SPA and a static binary
into a small Alpine image (~15 MB). Configuration enters the container via
WEFT_* environment variables; mount a TOML and pass -config for options
without an env equivalent (e.g. the [[user_attr]] tables). Started as root,
the privsep worker chroots to /var/empty and drops to _weft as usual; run
the container with a non-root user to skip chroot/privdrop.
compose.yml.example covers three ways to run weft;
copy it to compose.yaml first (that copy is gitignored, like weft.toml, so
your real network names never land in the repo):
cp compose.yml.example compose.yamlProfiles only add services, so pass the explicit names shown below
(otherwise the default weft service also starts and tries to reach networks
you may not have):
- Your own LDAP infrastructure (the default
weftservice, no profile): configure./weft.tomlwith your realldap_url,base_dn,admin_dnanddirectory(it's gitignored, so real settings never land in the repo), thendocker compose up --build weft. The container joins the externalfrontend/matrixnetworks declared at the bottom ofcompose.yaml— rename those to whatever your reverse proxy and LDAP stack actually use. - Self-contained demo stack, bundled OpenLDAP, no external infra needed:
docker compose --profile demo up --build openldap weft-demo— open http://localhost:8080, log in asadmin/adminpwand run the setup wizard. Plain LDAP + plain HTTP — evaluation only. - In-memory dev mode, no LDAP server at all, e.g. to try the bulk-import
wizard:
docker compose --profile dev up --build weft-dev— open http://localhost:8080, log in asadmin/rootpw. Data lives only in the container and is gone on restart.
Each release publishes a container image for linux/amd64 and linux/arm64 and a Helm chart to GHCR:
helm install weft oci://ghcr.io/isnogudus/charts/weft \
--set weft.ldapUrl=ldaps://ldap.example.org \
--set weft.baseDn=dc=example,dc=org \
--set weft.adminDn=cn=admin,dc=example,dc=orgThe image alone is ghcr.io/isnogudus/weft:<version> (also :<major>.<minor>
and :latest). The chart (charts/weft) runs weft the way a
cluster expects:
- One replica. Sessions live in memory, each holding the credentials weft re-binds with, so a second pod would not know the first one's logins. A pod restart logs everyone out; nothing else is lost, since weft stores nothing outside LDAP.
- Hardened pod. Non-root (UID 65532), read-only root filesystem, no capabilities, no service account token. Not started as root, weft skips chroot and privilege drop but keeps its monitor/worker split.
- Probes. Readiness follows
/api/healthz, so the pod drops out of the Service while LDAP is unreachable; liveness only checks the port, because restarting weft would not bring LDAP back. - Configuration. The
weftblock invalues.yamlcovers the core settings;weft.extraEnvtakes any furtherWEFT_*variable,weft.configTomlaweft.tomlfor what has no variable (the[[user_attr]]tables), andweft.caCertmounts the LDAP server's CA from a Secret or ConfigMap. Config changes roll the pod. - Ingress. Optional (
ingress.enabled); terminate TLS there, sincecookie_securestays on. Setweft.trustedProxiesto the Ingress controller's pod CIDR (e.g.["10.244.0.0/16"]), or the login rate limit sees only the controller and all clients share one bucket. Without one,kubectl port-forwardto the Service and open http://localhost:8080/.
- TLS to the LDAP server is enforced whenever credentials are sent (
plainrequires an explicit dev opt-in). - Sessions are server-side; the opaque id is an
HttpOnly; Secure; SameSite=Strictcookie. Bind credentials live only in server memory, never in the cookie, never logged. - CSRF: a synchronizer token (returned by
/loginand/me, echoed in theX-CSRF-Tokenheader) is required on all state-changing requests. - Login is rate-limited per client IP.
X-Forwarded-Foris only honoured when the connection comes from one of thetrusted_proxies, and then read from the right (the rightmost address that is not itself a trusted proxy), so a client cannot pick its own rate-limit bucket. Sessions expire aftersession_timeoutof inactivity (server-side, sliding); the SPA switches to the login view when a session expires. allow_admin = falseruns a self-service-only instance: the admin uid cannot log in at all (no management UI). The active mode is logged at startup.- No anonymous bind required. weft never binds or searches anonymously: the
bind DN is built directly from the login name (no search-then-bind), every
request re-binds with the session's credentials, and the health check only
opens/closes the connection without binding. Empty passwords are rejected
before the bind, so "unauthenticated bind" (DN + empty password) cannot fake a
login. The directory server may therefore disallow anonymous access entirely,
as hardening guides (e.g. BSI IT-Grundschutz) recommend — for OpenLDAP:
olcDisallows: bind_anonplusolcRequires: authc. Theby anonymous authACL rule (see OpenLDAP) stays compatible: it only permits the password check during the bind operation itself, not anonymous reads. - Passwords are hashed client-side (bcrypt) before
userPasswordis written; inputs longer than 72 bytes are rejected (bcrypt truncation). - Generated passwords ("Vorschlagen", bulk import) are random and easy to
type, e.g.
k3pa.7xmq.e2tn: 12 characters from lowercase letters and digits, at least one of each, 60 bits of entropy -- ample behind bcrypt. The dots are part of the password. There are no uppercase letters, no easily confusedlando, and noy/z, which QWERTY and QWERTZ keyboards swap. They are produced in the browser withcrypto.getRandomValues. - Password hashes are opaque to weft. It never reads
userPasswordto display, compare or verify it; only the directory server verifies it, on bind. The one read is the uid rename on ldapd, which has no ModifyDN and copies the value verbatim into the new entry (as the rootdn). That keeps theby self =wACL sufficient. The flip side: weft does not restore an entry's previoususerPasswordvalue. Where it puts an old password back, it hashes the plaintext again, so e.g. an{SSHA}value becomes{CRYPT}$2b$…for the same password. - Certificate verification can be skipped for a self-signed LDAP server via
insecure_skip_verify/-insecure(a startup warning is logged); prefer pinning the CA withca_cert_file.
Every non--dev run on Unix uses privilege separation (a monitor/worker
process split, below). Confinement happens after each process has read every
file it needs (config, CA bundle / system trust store, TLS keypair) and opened
its socket — the last step before serving, so nothing is read once the
filesystem and syscalls are restricted:
- When started as root, the worker
chroot(2)s tochroot(default/var/empty) and drops privileges touser/group(default_weft) — on Linux, macOS, FreeBSD and the BSDs. Without root those steps are skipped (the process split still applies). - On OpenBSD,
pledge(2)/unveil(2)additionally restrict each process to the minimal syscalls and paths it needs (see below).
The rc.d script starts weft as root so it can drop privileges itself (see
contrib/weft.rc). Controlled by sandbox / chroot /
user / group; sandbox = false keeps the process split but turns the
chroot/privdrop/pledge/unveil off. No-op on non-Unix platforms.
privsep is the process model for every non--dev run on Unix; weft runs as
two processes, in the style of OpenBSD daemons. (-dev uses the in-memory fake
and stays single-process.)
- A small privileged monitor opens connections to the LDAP server — DNS +
connect, for TCP or the ldapi Unix socket — and passes the connected file descriptors to the worker over asocketpairusingSCM_RIGHTS. It parses no request data. On OpenBSD it is pledged tostdio rpath inet dns unix sendfdand its filesystem view is restricted withunveil(2)to just the paths it actually opens —/etc/resolv.conf//etc/hosts(DNS), the ldapi socket, and/dev/log(syslog) — then locked. It stops the worker by closing a shutdown pipe (notkill(2)), so it needs noprocpromise; if the monitor dies the pipe closes too, so the worker is never orphaned. - An unprivileged worker (re-exec'd,
chrooted to/var/empty, dropped to_weft) serves HTTP and the JSON API and speaks LDAP over the descriptors it receives. It never needs DNS, the ldapi socket, or any filesystem, so the/var/emptychroot holds for every transport — hostname or ldapi included. On OpenBSD it is pledged tostdio inet recvfd.
Because the monitor re-dials on demand, idle-dropped connections recover (unlike
a static pre-opened pool). fd passing is a portable Unix mechanism, so privsep
works on Linux/macOS/FreeBSD too — only the pledge/unveil layer is
OpenBSD-specific.
- Configure
ldapd— seecontrib/ldapd.conf.examplefor the schema includes, therootdn/rootpw, and the exact ACLs (hideuserPasswordon read, allow read, allow bind, allowby selfpassword write). Reload withrcctl reload ldapd. - Create the service user and install the binary/config. weft starts as root
and drops to
_weftitself, so the config is owned by root:useradd -d /var/empty -s /sbin/nologin -L daemon _weft install -o root -g bin -m 0555 weft.openbsd-amd64 /usr/local/bin/weft install -o root -g wheel -m 0600 weft.toml /etc/weft.toml
- Install the rc.d script
contrib/weft.rcas/etc/rc.d/weft, thenrcctl enable weft && rcctl start weft. weft chroots to/var/emptyand drops to_weftafter startup (see Sandboxing; setsandbox=falseto opt out). - Terminate TLS in front of weft with
relayd(orhttpd) — seecontrib/relayd.conf.example. weft listens on127.0.0.1:8080; the proxy forwards the real client IP viaX-Forwarded-For, andtrusted_proxies = ["127.0.0.1"]inweft.tomltells weft to believe it, so the login rate limit keys on each client instead of on the proxy. (For a standalone setup without a proxy, settls_cert_file/tls_key_fileinweft.toml.) The example also carries a commented-outcheck http "/api/healthz" code 200— useful with more than one weft host, but see the note there before enabling it on a single-host table (weft's own error page is friendlier than relayd's). - Point your monitoring at
/api/healthz:200while weft can reach the directory,503while it cannot.
Under a supervisor instead of rc.d — e.g. runit — use
contrib/runit/: a run script that execs weft as root in
the foreground (weft re-execs the chrooted worker itself), plus a log/run that
captures the logs with svlogd.
weft logs to stderr (Go's standard logger — no syslog, no log file of its own), one line per request and for lifecycle events, so the process supervisor owns log capture and rotation:
- runit: the example
rundoesexec 2>&1and thelog/runservice runssvlogd -tt /var/log/weft→ timestamped, rotated logs in/var/log/weft. - OpenBSD
rc.d: stderr is not captured by default. Either run weft under a supervisor, or setlog = "syslog".
Set log = "syslog" (syslog_tag defaults to weft, facility LOG_DAEMON) to
write to the local syslog instead. Under privsep this is done right: the
non-chrooted monitor owns the syslog connection — it reconnects across
syslogd restarts (and falls back to stderr while syslog is unreachable) — and
forwards the chrooted worker's log lines to it (the worker can't reach
/dev/log from /var/empty, so it logs to stderr, which the monitor captures).
log_level sets how much is logged; it is independent of log, which only
picks the destination. Default info:
| Level | What it adds | Typical use |
|---|---|---|
debug |
one line per LDAP operation: dial, bind DN, search base/filter/result count, add/modify/delete DN | diagnosing directory problems |
info |
lifecycle events and one line per HTTP request | the default |
warn |
only problems — no access log | quiet production |
error |
only failures | very quiet |
./weft -config weft.toml -log-level debug # or WEFT_LOG_LEVEL=debugDebug lines never contain credentials: userPassword is written pre-hashed and
never logged, and a modify logs its attribute names, not their values. They do
contain DNs and search filters — that is the point of the level, but it is also
why debug is not a level to leave on. Under privilege separation both
processes resolve the same level (the worker is re-exec'd with the same argv and
environment), so LDAP debug lines from the worker appear in the same stream.
Towards syslog the level also picks the severity (LOG_DEBUG/LOG_INFO/
LOG_WARNING/LOG_ERR), so syslog.conf can filter on it. On stderr, info
lines are unprefixed and the others carry debug: / warning: / error: .
Credentials and password material are never logged. All logs — monitor and worker — end up in one stream.
GET /api/healthz is the probe for supervisors, container runtimes and
monitoring. It reports whether weft can reach the directory server:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/healthz
200 # 503 while the LDAP server is unreachable
{"status":"ok","ldap":"up"}- Key on the status code, not the body:
200healthy,503degraded. - The body says nothing else on purpose — no admin DN, no LDAP address, no
error text. The endpoint is unauthenticated; the reason for a failure is
logged server-side (at
warn) instead. - The probe is a connect and disconnect, no bind and no search, so it needs no credentials and no anonymous access, and it does not depend on the directory's ACLs.
- Results are cached for two seconds and shared with the SPA's
/setup/statuscall, so polling every few seconds does not mean one LDAP connection per request. Concurrent probes coalesce into one dial.
The Docker image ships a matching HEALTHCHECK, so docker ps /
docker compose ps show healthy/unhealthy out of the box. Override it if you
serve HTTPS directly (tls_cert_file) or listen on another port — the
built-in check hardcodes http://127.0.0.1:8080/api/healthz.
All under /api, JSON. Writes require the CSRF header.
POST /login POST /logout GET /me
GET /me/profile GET /me/groups POST /me/password
GET /healthz GET /setup/status GET /meta
POST /setup/bootstrap
GET/POST /users GET/PUT/DELETE /users/{uid}
POST /users/{uid}/password POST /users/{uid}/rename GET /users/{uid}/groups
GET/POST /groups DELETE /groups/{cn}
POST /groups/{cn}/members DELETE /groups/{cn}/members/{uid}
/users*, /groups* and /setup/bootstrap are admin-only; /me* is
available to every authenticated user. Besides /login, the unauthenticated
endpoints are GET /setup/status — reachability plus the admin identity for
the login screen, { reachable, adminUid, adminDn }, nothing about the
directory's contents — and GET /healthz (see below).
GET /users supports q (search term), page (1-based, default 1) and
pageSize (default 25, max 200), returning { users, total, page, pageSize }.
LDAP has no offset-based pagination, so the term-filtered result set is
fetched and sorted in full on every request; only the requested page is sent
to the browser (see internal/directory/ldapclient.ListUsers).
cmd/weft main: flags, config, wiring, HTTP server
internal/config TOML + env + flags, defaults, DN templates
internal/directory the Directory/Conn abstraction + sentinel errors
├── ldapclient go-ldap/v3 implementation (flavors: ldapd, openldap)
└── fake in-memory implementation for tests and -dev
internal/idalloc pure next-free-number allocation
internal/password bcrypt -> {CRYPT}
internal/service validation, hashing, id allocation, bootstrap
internal/server sessions, CSRF, rate limit, JSON handlers, SPA serving
web Svelte 5 SPA (Vite) + embed.go
charts/weft Helm chart (Kubernetes)
contrib ldapd.conf / relayd.conf / rc.d examples
make test runs everything against the Fake directory — no OpenBSD or ldapd
required. The Fake mirrors ldapd's authorization (admin writes all, users write
only their own entry) and verifies binds against the stored bcrypt hash, so the
HTTP integration tests exercise realistic auth flows. For true integration
testing, ldapd is OpenBSD-only; run a VM (e.g. via vmd) or test against the
binary in an OpenBSD CI runner.
MIT — see LICENSE.