# Theta42 SSO Manager with OpenLDAP - All-in-One Dockerfile # App + OpenLDAP in a single container, for development/testing or a # self-contained single-node deployment. For production, run a dedicated # LDAP server and configure the app via app_* env vars / mounted secrets.js. # ── Git commit hash (build-time only) ──────────────────────────────────────── # The final image intentionally has no git binary and no .git directory (kept # lean, per .dockerignore), so `git rev-parse` always fails at runtime and # build_info.js silently fell back to "unknown". Resolve it here instead, # where .git IS available (build context), and bake just the short hash into # a file — this stage itself is discarded, only /commit.txt survives via the # COPY --from below. Reuses the main base image (already pulled for the real # build below) rather than a separate one, so this adds no extra image pull. # # GIT_COMMIT lets a caller override the resolved hash instead of computing it # from .git in this build context. Needed when this repo is built as a git # submodule (e.g. from theta-env): a submodule's .git is a pointer FILE, not # a directory — the real object database lives in the superproject's # .git/modules/, outside this repo's own directory and therefore outside # Docker's build context entirely, so `git rev-parse` can never resolve it # from in here no matter what. theta-env's setup.sh passes --build-arg # GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on # the host where the submodule resolves correctly. ARG GIT_COMMIT="" FROM node:20-alpine AS gitinfo ARG GIT_COMMIT WORKDIR /repo COPY .git ./.git RUN if [ -n "$GIT_COMMIT" ]; then \ echo "$GIT_COMMIT" > /commit.txt; \ else \ { apk add --no-cache git \ && git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \ fi # ── OpenLDAP from source ───────────────────────────────────────────────────── # We build slapd from OpenLDAP master rather than installing Alpine's packages, # for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu, # 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x # release can do this -- verified: 2.6.13 ships 26 overlay modules and # nestgroup is not among them -- and the alternative is resolving nesting # separately in every consumer (this app, SSSD on each host, jump-host, proxy), # where any consumer that forgets silently under-grants access. # # Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the # 0.9.x used by 2.6.x cannot read, and vice versa # ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto # this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md. FROM node:20-alpine AS ldapbuild # groff is not optional despite producing nothing we ship: the build descends # into doc/man unconditionally and its Makefile calls soelim, which groff # provides. Without it the whole `make` fails at the man-page stage # ("soelim: not found") long after slapd itself has compiled fine. RUN apk add --no-cache \ build-base autoconf automake libtool \ openssl-dev cyrus-sasl-dev \ git make pkgconf util-linux-dev groff # Pinned to an exact commit, not a branch tip. This is the directory server the # whole lab authenticates against; an unpinned `master` would mean every image # rebuild silently ships whatever landed upstream that morning, and a bad day on # master would take out logins with no way to tell what changed. # # TODO: drop this whole from-source stage once nestgroup ships in a release. # It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped # from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with # nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` -- # the entrypoint already probes for nestgroup.so and needs no change, and the # app already keys off app_ldap__nestedGroupsServerSide either way. ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d WORKDIR /src RUN git init -q . \ && git remote add origin https://git.openldap.org/openldap/openldap.git \ && git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \ && git checkout -q FETCH_HEAD \ && git rev-parse HEAD > /opt-openldap-commit.txt # Overlays are built as loadable modules (=mod) because docker-entrypoint.sh # `moduleload`s them individually; nestgroup joins that set. RUN ./configure \ --prefix=/opt/openldap \ --enable-slapd \ --enable-modules \ --enable-mdb \ --enable-memberof=mod \ --enable-refint=mod \ --enable-ppolicy=mod \ --enable-dynlist=mod \ --enable-nestgroup=mod \ --enable-syncprov=mod \ --enable-auditlog=mod \ --with-tls=openssl \ --with-cyrus-sasl \ && make depend \ && make -j"$(nproc)" \ && make install # pw-sha2 provides {SSHA512}, which every existing user password is stored as. # It lives in contrib and is not covered by the configure flags above, so it is # built separately against the just-built tree -- omitting it would make every # user password unverifiable. RUN cd contrib/slapd-modules/passwd/sha2 \ && make prefix=/opt/openldap OPENLDAP_SRC=/src \ && cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/ FROM node:20-alpine # Runtime libraries the from-source slapd links against, plus the app's own # deps. No openldap* packages here: everything LDAP comes from /opt/openldap. # libltdl (module loading -- slapd is useless without it, since every overlay # is a loadable module) and libuuid are pulled in by the source build but are # NOT dependencies of anything else here, so they must be named explicitly; # omitting them fails at runtime with "Error relocating ... lt_dlopenext: # symbol not found", not at build time. RUN apk add --no-cache \ openssl \ libsasl \ libltdl \ libuuid \ dumb-init \ bash \ redis \ nmap \ && rm -rf /var/cache/apk/* COPY --from=ldapbuild /opt/openldap /opt/openldap # Which upstream commit this slapd was built from — so a running container can # answer "what am I actually running" without rebuilding. COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT # The Alpine openldap package used to create these; nothing does now, and # docker-entrypoint.sh runs slapd as -u ldap -g ldap. RUN addgroup -S ldap 2>/dev/null || true \ && adduser -S -D -H -G ldap ldap 2>/dev/null || true # docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name # and probes a list of candidate module directories, so putting the from-source # tree first on PATH is all that is needed to redirect it. Schemas are symlinked # into the conventional location because the entrypoint's slapd.conf includes # /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo, # openssh-lpk) are copied there too. ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}" RUN mkdir -p /etc/openldap/schema \ && for f in /opt/openldap/etc/openldap/schema/*.schema; do \ ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \ done WORKDIR /app # Create required directories. slapd runs as the ldap user; the app process # runs as root in this image (matches the bare-metal systemd unit). RUN mkdir -p /var/lib/ldap /etc/ldap/sasl2 && \ chown -R ldap:ldap /var/lib/ldap /etc/ldap/sasl2 # Copy application source and install production dependencies. # .dockerignore excludes nodejs/node_modules so npm ci builds a clean tree. COPY nodejs/package*.json ./ RUN npm ci --omit=dev COPY nodejs/app.js ./ COPY nodejs/bin ./bin COPY nodejs/conf ./conf COPY nodejs/controller ./controller COPY nodejs/middleware ./middleware COPY nodejs/models ./models COPY nodejs/routes ./routes COPY nodejs/services ./services COPY nodejs/utils ./utils COPY nodejs/views ./views COPY nodejs/public ./public COPY nodejs/plugins ./plugins # routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the # app flattened into /app, __dirname is /app/routes and ../../ resolves to /, # so the file must exist at /tos.md (mirroring the repo where tos.md sits one # level above the nodejs/ app dir). Without this the app crashes on startup. COPY tos.md /tos.md # Documentation, served in-app at /docs (routes/docs.js) so it's readable # without internet access. Same flattened-path convention as tos.md above. COPY README.md /README.md COPY CHANGELOG.md /CHANGELOG.md COPY DEPLOYMENT.md /DEPLOYMENT.md COPY API.md /API.md COPY directory_spec.md /directory_spec.md COPY docs /docs # Baked commit hash from the gitinfo stage (see build_info.js). COPY --from=gitinfo /commit.txt ./.build_commit # Copy startup script COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Custom LDAP schemas the app's user model depends on. addPosixAccount # (nodejs/models/user_ldap.js) tags every user entry with objectClasses # theta42Person, sudoRole, and ldapPublicKey and writes dateOfBirth / # sudoHost,sudoCommand,sudoUser / sshPublicKey — so the directory must know # all three or user create/update fails (theta42Person: LDAP 0x15; sudoRole / # ldapPublicKey: objectClassViolation 65). .dockerignore excludes ops/ from # the build context except ops/schema/*.schema, which is why these COPYs work. # theta42.schema : dateOfBirth + theta42Person (mirrors ldap-setup.sh §5) # sudo.schema : sudoRole (AUXILIARY — see file header) + sudo* attributes # openssh-lpk.schema : sshPublicKey + ldapPublicKey (AUXILIARY) COPY ops/schema/theta42.schema /etc/openldap/schema/theta42.schema COPY ops/schema/sudo.schema /etc/openldap/schema/sudo.schema COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema # Expose ports # 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy) # 389: LDAP (plain + StartTLS) — used internally by the app; map to host only # if you want LAN clients to bind without TLS (not recommended). # 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth # (PAM/SSSD, sudo, SSH keys) and LDAP-native apps EXPOSE 3001 389 636 # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 # dumb-init reaps zombies and forwards signals to the node process the # entrypoint execs into. Without it SIGTERM from `docker stop` is ignored # and the container hits the 10s kill timeout. ENTRYPOINT ["dumb-init", "/usr/local/bin/docker-entrypoint.sh"] CMD ["node", "bin/www"]