A small, opinionated web UI to administer users and groups in an existing,
external LDAP server — primarily OpenBSD ldapd(8).
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.
- 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).
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. - Everyone else = self-service only. They may view their own profile/groups
and change their own password (
by selfwrite, restricted touserPassword).
If you need multiple delegated admins or group-based ACLs, use OpenLDAP instead.
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.
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 reads or verifies hashes itself |
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), -dev, -version. Key env overrides: WEFT_LDAP_URL,
WEFT_BASE_DN, WEFT_ADMIN_UID, WEFT_ADMIN_DN, WEFT_LISTEN_ADDR,
WEFT_TLS_MODE, WEFT_SESSION_TIMEOUT, WEFT_INSECURE_SKIP_VERIFY.
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.
On first start weft checks whether ou=people exists. If not, the UI shows a
setup wizard: enter the ldapd rootpw and weft binds once as the rootdn to
create the base/suffix entry, ou=people, ou=groups, and the default users
group. Afterwards log in as the admin uid (e.g. admin) with the rootpw. The
wizard is idempotent, so it is safe to re-run.
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).
- 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. Sessions expire after
session_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.- Passwords are hashed client-side (bcrypt) before
userPasswordis written; inputs longer than 72 bytes are rejected (bcrypt truncation). - 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 should forward the real client IP viaX-Forwarded-Forso the login rate limit keys correctly. (For a standalone setup without a proxy, settls_cert_file/tls_key_fileinweft.toml.)
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).
Credentials and password material are never logged. All logs — monitor and worker — end up in one stream.
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 /setup/status POST /setup/bootstrap GET /meta
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* and /groups* are admin-only; /me* is available to every
authenticated user.
cmd/weft main: flags, config, wiring, HTTP server
internal/config TOML + env + flags, defaults, DN templates
internal/directory the Directory/Conn abstraction + sentinel errors
├── ldapd go-ldap/v3 implementation against ldapd
└── 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
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.