diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..31f00f1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,512 @@ +# Deployment Guide — SSO Manager + +Two supported deployment methods: + +1. **Docker** — a single all-in-one image bundling the app + OpenLDAP + Redis (`docker compose up`). +2. **Bare metal** — `install.sh` on Debian/Ubuntu (installs Node.js, OpenLDAP, Redis, the app, and a systemd unit). + +## How configuration works + +The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which deep-merges, in order: + +1. `conf/base.js` (committed, generic defaults) +2. `conf/.js` (optional) +3. `conf/secrets.js` (gitignored — secrets + per-deployment values) +4. **`app_*` environment variables** — the highest-precedence layer + +Any env var whose name starts with `app_` overrides the merged config. The rest +of the name is split on **double-underscore** (`__`) into a nested path. Values +are `JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept +as raw strings otherwise. Examples: + +| Env var | Sets | Type | +|---------|------|------| +| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string | +| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string | +| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) | +| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string | +| `app_smtp__secure=false` | `conf.smtp.secure` | boolean | +| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number | +| `app_name=My SSO` | `conf.name` | string | + +> **Requires `@simpleworkjs/conf` >= 1.1.0.** The Docker image will not honor +> `app_*` env vars on 1.0.0. Before building the image, refresh the app's +> dependency lock from the `nodejs/` directory: +> ```bash +> cd nodejs && npm install @simpleworkjs/conf@^1.1.0 +> ``` + +--- + +## Method 1: Docker (all-in-one) + +The image (`Dockerfile.openldap`) bundles OpenLDAP, Redis, and the app in one container. +The app connects to the bundled slapd over `localhost:389` automatically; you only +need to set a few secrets. + +### Setup + +The bundled `docker-compose.yml` reads config from a bind-mounted +`./config/sso-secrets.js` (not from a `.env` file). Copy the example, fill in +your secrets, then build + start: + +```bash +mkdir -p config && chmod 700 config +cp secrets.js.example config/sso-secrets.js +$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ... +docker compose up -d --build +``` + +`docker-entrypoint.sh` symlinks `/config/sso-secrets.js` → `/app/conf/secrets.js` +so `@simpleworkjs/conf` reads it, and pulls the server-side LDAP vars (base DN, +admin password, org, domain, cert CN, JWT secret) out of the same file. No +`app_*` env is passed — `app_*` env would override `secrets.js` (env beats the +file in `@simpleworkjs/conf`), so the file is kept authoritative. + +> **Your domain is entered once, as the LDAP base DN.** Set `stack.ldapBaseDn` +> (e.g. `dc=718it,dc=biz`) and keep the LDAP DNs consistent with it — they all +> derive from that one value: `ldap.bindDN` = `cn=admin,`, +> `ldap.userBase` = `ou=people,`, `ldap.groupBase` = `ou=groups,`, +> and `stack.ldapDomain` = the dotted form (`718it.biz`). `oauth.issuer` is the +> public SSO URL (`https://`). Drifting these apart (e.g. leaving +> `ldap.bindDN` at `dc=example,dc=com` while `stack.ldapBaseDn` is your real +> domain) makes the SSO bind against a non-existent root DN and every login +> fails with `Invalid Credentials`. +> +> Running the unified `theta-env` stack? You don't hand-edit these DNs at all +> — its `setup.sh` generates `./config/sso-secrets.js` (+ `./config/proxy-secrets.js`) +> from a single `setup.env` (where the domain is asked once, as the base DN) with +> random secrets, and snapshots state before rebuilds — so the DNs can't drift. +> See the theta-env README. + +**Quick test (defaults):** with no `./config/sso-secrets.js` the entrypoint +falls back to env-mode with safe defaults (`dc=example,dc=com`, admin password +`admin`, an auto-generated JWT secret) — fine for kicking the tires, not for +production. + +**Advanced — env vars instead of the file:** the entrypoint also supports +config via `LDAP_*` / `app_*` env vars (env-mode, used when +`/config/sso-secrets.js` is absent). Since the bundled compose no longer passes +those env vars, you'd add them to its `environment:` block yourself, e.g. +`LDAP_ADMIN_PASS`, `JWT_SECRET`, `app_oauth__issuer`. This is mainly for +bare-metal / advanced standalone use; most deployments should use the file. + +### What the entrypoint does + +`docker-entrypoint.sh` (run as the container entrypoint): + +1. If `/config/sso-secrets.js` is mounted, symlinks it to `/app/conf/secrets.js` + and reads the server-side LDAP vars from it (secrets.js mode). Otherwise it + derives them from `LDAP_*` env vars with safe defaults (env mode). +2. Generates a self-signed TLS cert (unless one is already present at + `LDAP_CERT_DIR`), generates a `slapd.conf` for the bundled OpenLDAP (`mdb` + database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS, + indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf` + listening on `ldap:///` (389) and `ldaps:///` (636). +3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default + `pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`, + `app_sso_oauth_admin`, `app_sso_service_account`) — idempotently, so + container restarts are safe. +4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and + stores OAuth clients there), AOF+RDB persisted to `/data`, unless + `app_redis__host` is set (then it's expected to be external). +5. In env mode, exports `app_*` env vars so the app binds to the local slapd. In + secrets.js mode it exports none (the app reads the file directly). +6. `exec`s `node bin/www`. + +### Access + +- SSO Manager UI: `http://localhost:3001` (HTTP inside the container — put a TLS-terminating proxy in front for browser access) +- Health check: `http://localhost:3001/health` → `{"status":"ok"}` +- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` +- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host) +- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://:636` (TLS) + +### API tokens (personal access tokens) + +Any logged-in user can mint a long-lived bearer token to call the management +API from scripts/CI/other services, without a browser session. Tokens are +self-service and authenticate **as their creator** — a token carries the +creator's LDAP group permissions, so the same `permission.byGroup` checks apply +(group membership is re-resolved from LDAP live on each request). + +Create one in the UI under **API Tokens** (the token string is shown **once**), +then use it as a bearer token: + +```bash +curl -H "Authorization: Bearer sso__" https://sso.example.com/api/user +``` + +Format: `sso__` — the `id` is the lookup key, the `secret` is +bcrypt-hashed and never stored in plaintext. Rotate or revoke a token from the +same UI page; revocation takes effect immediately. Optional expiry (in days) at +creation. API tokens persist in the bundled Redis, so they survive rebuilds +(Redis is persisted via AOF — see *Backups and restore*). + +The token has the same access as a browser session for that user — an +`app_sso_admin`'s token can manage users/groups; a non-admin's token is limited +to what they could do in the UI. + +### Logs + +The all-in-one image runs the Node app and slapd (OpenLDAP) in one container, +both writing to the container's stdout/stderr, so `docker compose logs` is the +primary view (slapd runs with `-d 0`, so LDAP output is there too). + +```bash +docker compose logs -f sso-manager # app + slapd (stdout/stderr) +docker compose logs --tail=200 --since=10m sso-manager # recent context +docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,$LDAP_BASE_DN" -W -b "$LDAP_BASE_DN" # LDAP health check +``` + +### Available environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LDAP_BASE_DN` | `dc=example,dc=com` | slapd suffix + app user/group base | +| `LDAP_DOMAIN` | derived from `LDAP_BASE_DN` | DNS domain; default for `LDAP_CERT_CN` and OAuth issuer | +| `LDAP_ADMIN_PASS` | `admin` | slapd root password + app bind password | +| `ORG_NAME` | `SSO Manager` | org name in UI/email/group descriptions | +| `JWT_SECRET` | auto-generated | OAuth JWT signing secret (persist it!) | +| `OAUTH_ISSUER` | `https://sso.` | OIDC issuer in the discovery doc (browser-facing URL) | +| `LDAP_CERT_CN` | `LDAP_DOMAIN` | CN/SAN on the LDAPS cert (hostname clients verify against) | +| `LDAP_CERT_DIR` | `/etc/openldap/certs` | where the entrypoint looks for `ldap.crt`+`ldap.key` (mount your own here) | +| `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`/`SMTP_FROM` | localhost / 587 / empty | outbound email | +| `PORT` | `3001` | host port mapped to the UI | +| `LDAPS_PORT` | `636` | host port mapped to LDAPS | +| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) | +| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication | +| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) | + +Any `app_*` var may also be set directly to override any config value (see the +table at the top). + +### LDAP TLS (LDAPS / StartTLS) + +The bundled slapd generates a **self-signed cert** on first start (CN = `LDAP_CERT_CN`, +valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on +`ldaps:///` (636) plus offers StartTLS on `ldap:///` (389). The cert is stored on the +`ldap-certs` volume so it persists across container recreation — clients don't need +to re-trust on every rebuild. + +The `/integrations` page derives its LDAPS URL from the OAuth issuer by default. +To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com` +or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your +secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for +recommended network layouts and how to match the cert SAN to the hostname. + +- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt` + out of the container and add it to the client's trusted CA store, or set + `TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with: + ```bash + docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt + ``` +- **Use your own cert** (CA-signed / internal CA): replace the `ldap-certs` named + volume with a bind mount containing your own `ldap.crt` + `ldap.key`: + ```yaml + volumes: + - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key + ``` + The entrypoint leaves existing certs untouched (idempotent). + +> Port 389 (plain LDAP) is **not** mapped to the host by default, to avoid cleartext +> password binds over the LAN. Direct-LDAP clients should use LDAPS (636) or +> StartTLS. Uncomment the `389` mapping in `docker-compose.yml` only if you need +> plain LAN binds and accept the risk. + +### Fronting with a reverse proxy (theta42/proxy) + +The SSO Manager runs HTTP inside the container; terminate TLS at a front proxy. +The [`theta42/proxy`](https://github.com/theta42/proxy) is an OIDC-protected reverse +proxy and a natural fit — it's both an **OIDC client** of the SSO Manager *and* a +**direct LDAP client** for user lookups. To run both together: + +1. **Put them on one Docker network** so the proxy can reach the SSO Manager + internally at `http://sso-manager:3001` for token/userinfo (server-to-server), + without exposing the SSO Manager's HTTP port to the internet: + ```yaml + # in the proxy's compose, or a shared external network: + networks: + - sso-net + ``` +2. **Set the SSO's `OAUTH_ISSUER`** to the *browser-facing* HTTPS URL the proxy + serves the SSO at (e.g. `https://sso.yourdomain.com`). The proxy's + `oidc.issuer`/endpoints must match — it can get them from the SSO's + `/.well-known/openid-configuration`. Server-to-server calls from the proxy go to + the internal `http://sso-manager:3001` URL; only the issuer/redirect URLs must + be public. +3. **Register the proxy as an OAuth/OIDC client** in the SSO Manager UI, with a + `redirectUri` matching the proxy's callback (e.g. + `https://proxy.yourdomain.com/api/auth/oidc/callback`), and put the client + secret in the proxy's `secrets.js`. +4. **LDAP for the proxy**: point the proxy's `ldap.url` at + `ldaps://sso-manager:636` (TLS, same Docker network) rather than a LAN IP, and + create a dedicated LDAP service account under `ou=people` (e.g. + `cn=ldapclient,ou=people,…`) via the SSO Manager UI — don't reuse the admin DN. + +### Backups and restore + +**What lives where** + +| State | Location | Persisted? | +|-------|----------|------------| +| LDAP directory (users, groups, policies) | `ldap-data` volume (`/var/lib/ldap`) | yes (volume) | +| LDAP TLS cert | `ldap-certs` volume (`/etc/openldap/certs`) | yes (volume) | +| Redis (OAuth clients, tokens, sessions) | `sso-data` volume (`/data`) | yes (AOF + RDB) | +| Secrets (LDAP admin pass, JWT secret, SMTP) | `./config/sso-secrets.js` (bind mount) | your responsibility — back up off-host | + +**Automatic snapshots** — when run as part of the unified `theta-env` stack, +`setup.sh` snapshots LDAP + Redis + `./config/` to `./backups//` +before every rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone +deployments should run `ops/backup.sh` the same way (on a cron/systemd timer, +or by hand before an upgrade): + +```bash +./ops/backup.sh # keeps the last 5 by default +./ops/backup.sh 10 # or override retention +BACKUP_KEEP=10 ./ops/backup.sh +``` + +It snapshots LDAP (`slapcat`, auto-detecting your base DN from +`./config/sso-secrets.js`), Redis (`BGSAVE`, falling back to a synchronous +`SAVE` if that doesn't complete quickly), and `./config/` to +`./backups//`, pruning older backups beyond the retention count — +the same approach `theta-env`'s `setup.sh` uses, just scoped to this one +container. Equivalent manual steps, if you'd rather not use the script: + +```bash +# LDAP — full directory export (works while slapd is running) +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif + +# Redis — hot snapshot: trigger a save, then copy the RDB out +docker compose exec sso-manager redis-cli BGSAVE +docker compose cp sso-manager:/data/dump.rdb sso-redis-$(date +%F).rdb + +# Secrets — copy the config dir (holds LDAP_ADMIN_PASS, JWT secret, etc.) +cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F) +``` +Store the backup **off the host** — it contains secrets and the whole user +directory. + +**Restore — full (disaster recovery)** + +The SSO image uses a static `slapd.conf` (slapd starts with `-f`, not `-F` +cn=config), so LDAP restore uses `slapadd -f /etc/openldap/slapd.conf`: + +```bash +# 1. Secrets +cp -a config-backup- ./config && chmod 700 ./config +./setup.sh # fresh empty volumes (or: docker compose up -d) +docker compose stop sso-manager + +# 2. LDAP — wipe the mdb files, then load the LDIF into the stopped directory +docker compose run --rm --no-deps --entrypoint sh sso-manager -c \ + 'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \ + < ldap-backup-.ldif +docker compose start sso-manager + +# 3. Redis — see the AOF note below +docker compose stop sso-manager +docker compose run --rm --no-deps --entrypoint sh sso-manager -c \ + 'rm -f /data/appendonly.aof /data/appendonly.aof.*' # REQUIRED — see note +docker compose cp sso-redis-.rdb sso-manager:/data/dump.rdb +docker compose start sso-manager +``` + +**Restore — Redis only** = step 3 above. **Restore — LDAP only** = step 2 above. + +> **AOF vs RDB (important):** with `--appendonly yes`, Redis loads +> `appendonly.aof` on startup and **ignores** `dump.rdb` if the AOF exists. To +> restore from an RDB snapshot you **must delete the AOF first** (step 3 does +> this); Redis then loads the RDB and writes a fresh AOF. Verify after restoring: +> `docker compose exec sso-manager redis-cli DBSIZE` and +> `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. + +**Upgrades** + +```bash +./setup.sh # backs up, then rebuilds — volumes keep LDAP + Redis state +# (standalone) docker compose pull && docker compose up -d +``` +LDAP data and Redis state survive the rebuild because they live on named +volumes, not in the image. Verify health (`docker compose ps`, log in, check an +OAuth client). Note: re-running bootstrap resets the bootstrap-admin and +service-account passwords to the values in `./config/sso-secrets.js`; non-theta +OAuth clients live in SSO Redis and are preserved by the volume. + +> **Note — the bundled slapd is built from source.** The all-in-one image +> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup` +> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release +> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is +> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x +> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x +> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same +> shape as "Restore — LDAP only" above. Verify after a rebuild: +> `docker compose logs sso-manager | grep nestgroup` should report the overlay +> as available. + +--- + +## Method 2: Bare metal (Debian/Ubuntu) + +`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis, +force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd +config from the repo. Re-run it to update — it prints the version you're +updating from and to (or "Already up to date" if there's nothing new). + +On the **first run only** it also installs and configures OpenLDAP (modules + +overlays + custom schema + directory tree + required groups — see +`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated +LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that +file exists it's never touched again, and LDAP is never re-bootstrapped — +edit the file and restart the service to change anything. + +### Prerequisites + +- Debian 11+ / Ubuntu 20.04+ +- Root (`sudo`) +- Internet access + +### Install + +```bash +wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash +``` + +or, if you already have the repo checked out: + +```bash +sudo ./install.sh +``` + +| Env var | Description | +|---------|-------------| +| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only | +| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only | +| `JWT_SECRET` | JWT secret (default auto-generated) — first run only | +| `ORG_NAME` | Org name (default `SSO Manager`) — first run only | +| `PORT` | HTTP port (default `3001`) — first run only | +| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) | +| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults | + +### Post-install + +```bash +sudo systemctl status sso-manager +journalctl -fu sso-manager +curl http://localhost:3001/health # -> {"status":"ok"} +``` + +### What `install.sh` does + +1. Installs Node.js 22.x (NodeSource) and Redis. +2. Clones/updates the repo at `/opt/theta42/sso-manager`. +3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`, + `memberof`, `refint` modules + overlays; the custom `theta42Person` schema + (`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default + `pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`. +4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and + runs `npm ci --omit=dev`. +5. Enables and (re)starts the service. + +> For an existing LDAP server, run with `SKIP_LDAP=true` and write +> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before +> starting the service. To (re)configure overlays on an already-installed +> slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user +> database). + +--- + +## LDAP requirements (for any external LDAP server) + +The app needs these on the LDAP server: + +- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`), `ppolicy`, + `memberof`, `refint`. +- **Custom schema:** the `theta42Person` auxiliary objectClass with `dateOfBirth` + (OID `1.3.6.1.4.1.99999.x`) — see `ops/ldap-setup.sh` for the LDIF. +- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a + default `pwdPolicy` at `cn=ppolicy,ou=policies,`. +- **Required groups:** `app_sso_admin` (full admin), `app_sso_invite` (invitation + management), `app_sso_oauth_admin` (OAuth client management), + `app_sso_service_account` (not a permission — marks a `posixAccount` as a + non-person service account; see docs/ldap.md). + +`ops/ldap-setup.sh -p ` configures all of the above idempotently +against a running slapd (auto-detects the database holding your base DN, and +verifies `pwdAccountLockedTime` is live — the attribute the app's +active/inactive toggle depends on). + +--- + +## Migrating an existing instance to the generic defaults + +The committed `nodejs/conf/base.js` now ships **generic** defaults +(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried +Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth issuer). +If you run an existing instance off this repo: + +- Move those per-deployment, non-secret values (bind DN, user/group bases, SMTP + host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored + `conf/secrets.js`, **or** set them as `app_*` env vars. Secret values (LDAP bind + password, SMTP password, JWT secret) already belong in `secrets.js`. +- After the change, verify the merged config: `node -e "console.log(require('@simpleworkjs/conf'))"` from the `nodejs/` directory. + +--- + +## Troubleshooting + +### `503 OpenLDAP ppolicy overlay is not configured` +The ppolicy overlay isn't attached to the database holding your users, so the +active/inactive toggle can't set `pwdAccountLockedTime`. Run: +```bash +sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com +``` + +### App starts but LDAP operations 401 / "Invalid Credentials" +Check the merged LDAP config the app actually sees: +```bash +cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)" +``` +Confirm `url`/`bindDN`/`bindPassword`/`userBase` match your directory. Remember +`app_*` env vars override `secrets.js` which overrides `base.js`. + +### `app_*` env vars seem to do nothing +You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+: +```bash +cd nodejs && npm install @simpleworkjs/conf@^1.1.0 +``` + +### LDAP connection refused +```bash +docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base' +systemctl status slapd # bare metal +netstat -tlnp | grep 389 +``` + +--- + +## Security notes + +1. **Never commit `secrets.js`** — it's in `.gitignore`. +2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The + bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain + + StartTLS); port 389 is not mapped to the host by default so LAN clients can't + bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps, + `theta42/proxy`) should use `ldaps://…:636` or StartTLS. +3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't + set `JWT_SECRET`, issued tokens invalidate on container recreation. +4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front + proxy and keep `3001` on the Docker network / localhost only. +5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to the + host by default for LAN/VPN clients that bind LDAP directly (other hosts + running `ldap-client`, apps with their own LDAP auth settings) — not for + exposure through your router/firewall. LDAP simple-bind is a brute-force + target with no rate limiting in front of it the way the HTTP login endpoints + have. If a remote host needs to bind LDAP, put it behind a VPN (Tailscale, + WireGuard, …) instead of forwarding 636 publicly. +6. The all-in-one image runs slapd as the `ldap` user but the app process as root + (matches the bare-metal systemd unit). Harden the app to a non-root user for + production if needed. \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..f07b372 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,55 @@ +title: SSO Manager +description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI, for home labs and small businesses that want their own identity provider. +url: "https://theta42.github.io" +baseurl: "/sso-manager-node" +logo: /assets/img/theta42.svg +lang: en_US + +plugins: + - jekyll-seo-tag + - jekyll-sitemap + +github: + repository_url: https://github.com/theta42/sso-manager-node + zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip + tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz + repository_name: theta42/sso-manager-node + +nav: + - title: Home + page: / + icon: fa-house + - title: Deployment + page: /deployment.html + icon: fa-server + - title: Configuration + page: /configuration.html + icon: fa-gears + - title: OAuth + page: /oauth.html + icon: fa-key + - title: LDAP + page: /ldap.html + icon: fa-address-book + - title: Directory + page: /directory.html + icon: fa-server + - title: Plugins + page: /plugins.html + icon: fa-plug + # API.md lives at the repo root, not under docs/, so Jekyll never renders an + # api.html for it — link the source directly, same as the Changelog. + - title: API + url: https://github.com/theta42/sso-manager-node/blob/master/API.md + icon: fa-code + - title: Changelog + url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md + icon: fa-list + +defaults: + - scope: + path: "" + type: "pages" + values: + layout: default + image: /assets/img/theta42.svg diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 0000000..39af120 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,82 @@ + + + + + + + + {% seo title=false %} + {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} + + + + + + + + + +
+
+
+
+
+
+ {{ content }} +
+
+
+
+
+
+ + + + + + diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..44e6768 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,88 @@ +--- +layout: default +title: Discovery Agents +nav_order: 5 +--- + +# Discovery Agents + +The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs. + +## Writing a Custom Agent + +Agents are simple JavaScript files placed in `nodejs/agents/discovery/`. + +A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`. + +### Agent Skeleton + +```javascript +// nodejs/agents/discovery/my_custom_agent.js +module.exports = { + discover: async (config) => { + const { url, apiKey } = config; // Provided by your configuration + + const resources = []; + const edges = []; + + // 1. Fetch your data from an API + // const data = await fetch(...); + + // 2. Map data to Resources + resources.push({ + kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device' + name: 'My Switch', + slug: 'my-switch-01', + metadata: { + make: 'Vendor', + model: 'Model X', + interfaces: [ + { mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' } + ] + } + }); + + // 3. Map relations to Edges (optional) + edges.push({ + parentSlug: 'my-switch-01', + childSlug: 'some-connected-client-slug', + relation: 'connected_to' // 'hosts', 'exposes', 'connected_to' + }); + + return { resources, edges }; + } +}; +``` + +## Configuration + +Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`: + +```javascript +module.exports = { + // ... existing config ... + discovery: { + agents: { + my_custom_agent: { + enabled: true, + cron: '*/30 * * * *', // Run every 30 minutes + url: 'https://api.example.com', + apiKey: 'secret-key' + }, + nmap: { + enabled: true, + cron: '0 * * * *', + targetRange: '192.168.1.0/24' + } + } + } +}; +``` + +## The Reconciliation Engine + +When your agent returns its graph, the Reconciliation Engine takes over: +1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`. +2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found). +3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp. +4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them. diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css new file mode 100644 index 0000000..e24a5de --- /dev/null +++ b/docs/assets/css/style.css @@ -0,0 +1,116 @@ +/* theta42 docs site — shares the in-app dark navbar/footer + card look + (Bootstrap 5 + Font Awesome, same as the running apps) rather than a + generic Jekyll theme. */ + +body { + background-color: #f4f5f6; +} + +.navbar-brand img { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4)); +} + +.navbar-nav .nav-link.active { + color: #fff; + font-weight: 600; +} + +/* Markdown content typography, scoped to the card body so it doesn't leak + into the nav/footer. */ +.site-content h1:first-child { + margin-top: 0; +} + +.site-content h1, +.site-content h2, +.site-content h3 { + font-weight: 700; +} + +.site-content h2 { + margin-top: 2.5rem; + padding-bottom: .4rem; + border-bottom: 1px solid #e9ecef; +} + +.site-content h3 { + margin-top: 1.75rem; +} + +.site-content a { + color: #a3671f; + text-decoration-color: rgba(163, 103, 31, .35); +} + +.site-content a:hover { + color: #8a5a16; +} + +.site-content pre { + background-color: #212529; + color: #f8f9fa; + padding: 1rem 1.25rem; + border-radius: .375rem; + overflow-x: auto; +} + +.site-content code { + color: #a3671f; + background-color: #f4f0e8; + padding: .15em .4em; + border-radius: .25rem; + font-size: .875em; +} + +.site-content pre code { + color: inherit; + background: none; + padding: 0; +} + +.site-content table { + display: block; + overflow-x: auto; + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; +} + +.site-content table th, +.site-content table td { + border: 1px solid #dee2e6; + padding: .5rem .75rem; + text-align: left; +} + +.site-content table th { + background-color: #f8f9fa; +} + +.site-content blockquote { + border-left: 4px solid #C59341; + padding: .5rem 1rem; + margin: 1.25rem 0; + background-color: #f8f6f1; + color: #495057; +} + +.site-content img { + max-width: 100%; + height: auto; +} + +/* Screenshot grids in the markdown use width="49%" inline attrs for a + two-up desktop layout -- stack them on narrow screens instead of + squeezing to illegibility. */ +@media (max-width: 576px) { + .site-content img[width] { + width: 100% !important; + margin-bottom: .75rem; + } +} + +.site-content hr { + margin: 2rem 0; + border-top: 1px solid #e9ecef; +} diff --git a/docs/assets/img/theta42.svg b/docs/assets/img/theta42.svg new file mode 100644 index 0000000..e598305 --- /dev/null +++ b/docs/assets/img/theta42.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 42 + + diff --git a/docs/concepts-accounts.md b/docs/concepts-accounts.md new file mode 100644 index 0000000..e782ab5 --- /dev/null +++ b/docs/concepts-accounts.md @@ -0,0 +1,125 @@ +--- +layout: default +title: Accounts, Groups & Managers +description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager. +--- + +# Accounts, Groups & Managers + +This page explains the concepts behind the Users and Groups pages in plain +language. If you want the technical schema/attribute-level detail instead, +see the [LDAP reference](ldap.html). + +## What's an account? + +Every person (or app) that can sign in through this SSO Manager has an +**account** — a username, a display name, maybe an email address, and a +password (or, for service accounts, no password at all — see below). +Accounts live in the directory this app manages, and any other app you've +connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these +same accounts instead of keeping its own separate list of users and +passwords. + +## Two kinds of account: people and service accounts + +Most accounts belong to an actual person — check **Users → People** to see +them. But sometimes you need an account for something that *isn't* a +person: a media server, a backup script, a bind account another app uses to +look people up. These are **service accounts**, listed separately under +**Users → Service Accounts**, and they're different from a person's account +in two ways that matter: + +- **No email required.** A service account doesn't need a mailbox, so the + form doesn't ask for one. +- **A password is optional.** If you leave it blank, nobody can log in as + that account — which is exactly what you want for something that only + ever gets used programmatically (a script authenticating with an API + token, or another app binding with a fixed, separately-configured + password you set yourself). Only give it a password if the account + genuinely needs to log in or bind somewhere as itself. + +Aside from those two differences, a service account is a completely normal +account under the hood — it can belong to groups, have a manager, and so +on, just like anyone else's. + +## Groups: who can do what + +A **group** is just a named list of accounts, used to control access. This +app has a handful of built-in groups that grant admin powers (e.g. only +people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview +pages at all), but you can also make your own groups for any app you +connect — say, a group listing everyone who should be allowed into your +photo server. Once a group exists, add or remove members from the +**Groups** page, and point the other app's "who's allowed in" setting at +that group's name. + +### Groups inside groups + +A group can contain another group, not just people — the *Nested* tab on any +group card. Everyone in the inner group counts as a member of the outer one, +however many levels deep it goes. + +This is mostly a way to stop repeating yourself. Make one `developers` group, +nest it into the handful of things developers should reach, and adding a new +developer to that one group grants all of them at once — instead of adding them +to each individually and slowly drifting out of sync. The app already does this +for itself: super admins are nested into every resource's admin group, and each +admin group into its access group, so "can administer it" always implies "can +use it". + +Two things it won't let you do: put a group inside itself (directly or round a +longer loop), and empty a group completely — every group must keep at least one +member. + +A note if you also manage the directory by hand: a group's member list shows +what is *directly* listed on it. Someone who gets in through a nested group is +a real member but won't appear there — the **Nested** tab shows what is nested, +and the API's `effective` view lists everyone who actually gets in. + +## Every account's personal group + +Separately from the groups above, every single account — person or +service account — automatically gets its own small, personal group when +it's created, named after the account itself. Most of the time you'll +never think about this; it exists so that, on a Linux system connected to +this directory, each account "owns" its own files by default the same way +a normal Unix user account would. + +Occasionally you'll want to share that ownership with someone else — for +example, letting a second account also have write access to files a +service account owns. That's what the **"Members of ``'s group"** +section on a profile page is for: add another account there, and the +underlying Linux permissions treat them as if they belong to that same +personal group too. + +## What's a "manager"? + +Every account has one or more **managers** — the people allowed to edit +that account's profile (phone number, SSH key, home directory, and so on) +without needing full admin rights. By default, whoever created an account +(the admin who added it, or whoever sent the invite) becomes its first +manager, but you can add or remove managers later from the account's Edit +form. + +This is useful for service accounts especially: if a service account +belongs to a particular project or person, make them its manager so they +can maintain it — rotate its SSH key, adjust its description — without +needing to be a full SSO administrator. + +## Inviting someone vs. adding them yourself + +From the Users page you can either fill in someone's details yourself +("Add new user"), or send them an **invite** — an email (or a link you copy +and send however you like) that lets them pick their own username and +password. Either way, the resulting account is identical; invites are just +a convenience so you don't have to know someone's preferred username or +handle their password directly. + +## Want more detail? + +This page deliberately leaves out LDAP schema names, attribute types, and +protocol-level detail. If you're connecting a third-party app directly to +the LDAP directory, or you just want to know exactly what's stored where, +see the [LDAP reference](ldap.html). + +[← Back to Home](index.html) diff --git a/docs/concepts-api-tokens.md b/docs/concepts-api-tokens.md new file mode 100644 index 0000000..36eeb9d --- /dev/null +++ b/docs/concepts-api-tokens.md @@ -0,0 +1,59 @@ +--- +layout: default +title: API Tokens +description: A plain-language guide to personal access tokens in SSO Manager. +--- + +# API Tokens + +This page explains what an API token is and when you'd want one. For the +full list of API endpoints a token can call, see the +[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md). + +## What's an API token, in plain terms? + +Normally, you interact with this app by logging in through a web browser. +An **API token** (also called a personal access token, or PAT) is an +alternative way in — a long, random string that a script, a scheduled job, +or another program can use instead of a username and password, to act on +your behalf without a human typing a login in each time. + +If you've ever set up a script to talk to GitHub, GitLab, or a similar +service using a "token" instead of your real password, this is the same +idea. + +## When would you actually need one? + +Most people never need to create one of these — you'll only want a token +if you're automating something, for example: + +- A script that syncs users or groups from somewhere else into this SSO + Manager on a schedule. +- A backup or monitoring job that checks this app's health via its API. +- A CI/CD pipeline that needs to register or update an OAuth client + automatically. + +If you're not doing any of that, you don't need an API token — just log in +normally through the web UI. + +## How it works + +Create a token from your Profile page, give it a name so you remember what +it's for later, and optionally an expiry. You'll be shown the token's +value **exactly once** — copy it somewhere safe immediately, because it +can't be viewed again afterward (only revoked or rotated). Whatever script +or tool you're using it with sends it along with each request, the same +way a browser sends your login session. + +A token acts **as you**, with **your** permissions — if you're not an +admin, a token you create can't do admin-only things either. If you ever +suspect a token has leaked (ended up somewhere it shouldn't have, like a +public script or log file), revoke it immediately from your Profile page; +it stops working right away. + +## Want more detail? + +This page doesn't attempt to list every API endpoint or show request/ +response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md). + +[← Back to Home](index.html) diff --git a/docs/concepts-oauth-apps.md b/docs/concepts-oauth-apps.md new file mode 100644 index 0000000..5479056 --- /dev/null +++ b/docs/concepts-oauth-apps.md @@ -0,0 +1,79 @@ +--- +layout: default +title: Connecting Apps (Single Sign-On) +description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager. +--- + +# Connecting Apps (Single Sign-On) + +This page explains, in plain language, what happens when you "connect" an +app to your SSO Manager so people can log into it with their existing +account. For the technical endpoint/token detail, see the +[OAuth reference](oauth.html). + +## What does "single sign-on" actually mean? + +Instead of every app you run having its own separate list of usernames and +passwords, they all check with this SSO Manager instead. You log in once, +here, and any connected app trusts that login — no separate password to +remember or manage for each one. If you ever need to lock someone out +everywhere at once, you do it in one place (deactivate their account here) +instead of hunting down every app individually. + +The technology behind this is called **OAuth 2.0** and **OpenID Connect +(OIDC)** — you'll see both names used, often together, referring to the +same thing. You don't need to understand the protocol to use this page; +what matters practically is the handful of concepts below. + +## What's a "client"? + +Every app you connect is registered here as a **client** — a single entry +in the Directory representing that one app. Registering a client +gives you a **Client ID** and **Client Secret**: think of these like a +username and password, but for the *app itself* rather than for a person. +You paste them into the other app's own "Single Sign-On" or "OIDC" setup +screen, along with the discovery URL shown at the top of this page, and +that app is now able to ask this SSO Manager to authenticate people on its +behalf. + +**Treat the Client Secret like a password** — anyone who has it can +impersonate that app when talking to your SSO Manager. If you ever suspect +it's leaked, rotate it from the client's card. + +## What are "scopes"? + +**Scopes** control what information a connected app is allowed to ask for +about the person logging in — their username, email, group memberships, +and so on. Most apps tell you exactly which scopes they need in their own +setup instructions; when in doubt, the default set (`openid`, `profile`, +`email`, `groups`) covers what nearly every app expects. + +## "Restrict to Groups" + +By default, *any* account with an SSO Manager login can sign into a +connected app. If that's not what you want — say, a home automation +dashboard that only certain family members should reach — set **Restrict +to Groups** on that client to one of your [groups](concepts-accounts.html). +Only members of that group will be allowed to log into that particular +app; everyone else gets turned away at the login step, even though their +SSO Manager account still works everywhere else. + +## Redirect URIs + +A **Redirect URI** is the exact web address the connected app wants people +sent back to once they've logged in here — it's a security measure so an +attacker can't trick the login flow into redirecting somewhere else. The +app's own setup instructions will tell you this value; copy it in exactly +as given. If the app is reachable via more than one hostname (for example, +because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)), +this field supports wildcard patterns — see the inline help under the +field itself for the exact syntax. + +## Want more detail? + +This page intentionally skips the protocol-level detail (exact endpoint +URLs, token formats, claim names). If you're troubleshooting a connection +or building something against the API directly, see the +[OAuth reference](oauth.html). + +[← Back to Home](index.html) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..1e6c36c --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,104 @@ +--- +layout: default +title: Configuration +description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables. +--- + +# Configuration + +[← Back to Home](index.html) + +The app loads configuration via +[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which +deep-merges, in order (later wins): + +1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`, + `localhost`, `SSO Manager`). +2. `conf/.js` — optional, environment-specific. +3. `conf/secrets.js` — gitignored; secrets + per-deployment values. +4. **`app_*` environment variables** — the highest-precedence layer. + +Any env var whose name starts with `app_` overrides the merged config. The rest +of the name splits on **double-underscore** (`__`) into a nested path. Values are +`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as +raw strings otherwise. + +## Examples + +| Env var | Sets | Type | +|---------|------|------| +| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string | +| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string | +| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string | +| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) | +| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) | +| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) | +| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) | +| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string | +| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string | +| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number | +| `app_smtp__secure=false` | `conf.smtp.secure` | boolean | +| `app_smtp__host=smtp.example.com` | `conf.smtp.host` | string | +| `app_name=My SSO` | `conf.name` | string | +| `app_redis__host=redis.local` | `conf.redis.host` | string (external Redis) | + +## The `app_*` env layer requires conf >= 1.1.0 + +The `app_*` environment-variable override layer was added in +`@simpleworkjs/conf` **1.1.0**. On 1.0.0 the app ignores all `app_*` vars and only +reads `base.js` / `.js` / `secrets.js`. The Docker image will not honor +`app_*` env on 1.0.0. Refresh the lock from the `nodejs/` directory: + +```bash +cd nodejs && npm install @simpleworkjs/conf@^1.1.0 +``` + +## Inspecting the merged config + +From the `nodejs/` directory: + +```bash +node -e "console.log(require('@simpleworkjs/conf').ldap)" +node -e "console.log(require('@simpleworkjs/conf').oauth)" +node -e "console.log(require('@simpleworkjs/conf'))" # everything +``` + +Or, inside the running container: + +```bash +docker compose exec sso-manager node -e "console.log(require('@simpleworkjs/conf').ldap)" +``` + +`app_*` env vars override `secrets.js`, which overrides `base.js` — if a value +isn't what you expect, check those layers in that order. + +## Migrating an existing instance to the generic defaults + +The committed `nodejs/conf/base.js` ships **generic** defaults +(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried +Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth +issuer). If you run an existing instance off this repo: + +- Move per-deployment, non-secret values (bind DN, user/group bases, SMTP + host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored + `conf/secrets.js`, **or** set them as `app_*` env vars. +- Secret values (LDAP bind password, SMTP password, JWT secret) already belong + in `secrets.js`. + +## Troubleshooting `app_*` env vars + +### `app_*` vars seem to do nothing + +You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+ (above). + +### LDAP operations 401 / "Invalid Credentials" + +Check the merged LDAP config the app actually sees: + +```bash +cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)" +``` + +Confirm `url` / `bindDN` / `bindPassword` / `userBase` match your directory. + +[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..f4cb789 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,22 @@ +--- +layout: default +title: Deployment +description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups. +--- + +# Deployment Guide + +[← Back to Home](index.html) + +The full deployment guide — Docker (all-in-one image), bare-metal install, +the `app_*` env reference, backups, and the security notes (including why +LDAPS shouldn't be port-forwarded to the internet) — lives in one place to +avoid two copies drifting out of sync: + +**[DEPLOYMENT.md on GitHub](https://github.com/theta42/sso-manager-node/blob/master/DEPLOYMENT.md)** + +See also [Configuration](configuration.html) for the config layer merge +order, and [LDAP](ldap.html) for the directory layout and connecting a +3rd-party app. + +[← Back to Home](index.html) diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000..18b4a81 Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/directory.png b/docs/images/directory.png new file mode 100644 index 0000000..9b1a4b3 Binary files /dev/null and b/docs/images/directory.png differ diff --git a/docs/images/groups.png b/docs/images/groups.png new file mode 100644 index 0000000..ffcb461 Binary files /dev/null and b/docs/images/groups.png differ diff --git a/docs/images/oauth-clients.png b/docs/images/oauth-clients.png new file mode 100644 index 0000000..e8790d5 Binary files /dev/null and b/docs/images/oauth-clients.png differ diff --git a/docs/images/sites.png b/docs/images/sites.png new file mode 100644 index 0000000..797e8e8 Binary files /dev/null and b/docs/images/sites.png differ diff --git a/docs/images/users.png b/docs/images/users.png new file mode 100644 index 0000000..60be54a Binary files /dev/null and b/docs/images/users.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e6ffcfc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,85 @@ +--- +layout: default +title: Home +description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home. +--- + +# SSO Manager + +A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory** +and a web management UI — for home labs and small businesses that want their +own identity provider instead of a hosted one. + +One place to manage your users and groups, one login (OIDC) your modern apps +can use, and one LDAP directory your older or odder apps can bind to directly. +Everything runs on your own hardware; no phone-home, no hosted control plane, +no per-user pricing. + +Part of the theta42 self-hosted identity stack, alongside +[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy) +and [theta-env](https://theta42.github.io/theta-env/) (the two composed with +one command). + +## Screenshots + +Overview dashboard +User list +Groups +Directory & inventory +OAuth client (edit view) + +*(click any screenshot to view full size)* + +## Why this over the alternatives + +Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but +LDAP is either a paid feature, a federation target you have to run +separately, or absent. If your stack already has apps that speak LDAP +directly — or you just want one real directory as the source of truth — you +end up running *two* identity systems and keeping them in sync. + +SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC +apps and LDAP apps read from the same users and groups. The trade-off is +scope: it's intentionally small and self-hosted, not an enterprise IAM suite. +If you want a lightweight, self-contained identity provider with a real LDAP +backend, that's the niche. + +## Features + +- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID + tokens; standard discovery document at `/.well-known/openid-configuration`. +- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public + keys, and sudo roles, with `memberOf` + referential-integrity overlays. +- **Web management UI** — users, groups, and OAuth clients from a browser; + invite and password-reset flows over email; self-service profile + API + tokens. +- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts + via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same + directory. +- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or + run the pieces separately via `app_*` env config. +- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites. +- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/). + +## Get it + +```bash +git clone https://github.com/theta42/sso-manager-node.git +cd sso-manager-node +cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env +docker compose up -d --build +``` + +That's the standalone quick start. For the full set of install options +(Docker, bare-metal, or as part of the combined SSO + proxy stack), the +`app_*` env reference, and the OAuth/LDAP internals, see the +**[GitHub repository](https://github.com/theta42/sso-manager-node)**. + +## Related projects + +- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware + reverse proxy, designed to sit in front of this SSO. +- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that + uses this SSO's directory to decide who may reach which machine. +- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO + Manager and the proxy together with one command. diff --git a/docs/ldap.md b/docs/ldap.md new file mode 100644 index 0000000..dd405bb --- /dev/null +++ b/docs/ldap.md @@ -0,0 +1,432 @@ +--- +layout: default +title: LDAP +description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly. +--- + +# LDAP Directory + +[← Back to Home](index.html) + +> Looking for a plainer explanation of accounts, groups, and managers +> instead of schema/attribute detail? See +> [Accounts, Groups & Managers](concepts-accounts.html). + +SSO Manager runs an OpenLDAP directory holding your users and groups. The app +authenticates against it over `localhost:389` (inside the all-in-one container) +and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP +directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the +theta42/proxy, etc. + +## Directory layout + +``` +dc=yourdomain,dc=com +├── ou=people users (inetOrgPerson + posixAccount + …) +├── ou=groups groups (groupOfNames) +└── ou=policies password policies (pwdPolicy) + └── cn=ppolicy default policy +``` + +### Users + +User entries are `cn=,ou=people,` and carry the objectClasses: + +- `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs. +- `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's + `userFilter` is `(objectClass=posixAccount)`, so a user is "a real account" + iff it has `posixAccount`. +- `ldapPublicKey` — SSH public keys (`sshPublicKey`). +- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`). +- `theta42Person` (custom auxiliary; `dateOfBirth`). + +Every user (person or service account) also carries a `manager` attribute +(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of +the people who created/administer that account. Set automatically to the +creator's DN on signup (whoever an admin was logged in as, or whoever sent +the invite), and reassignable later from the account's Edit form. Anyone +listed as a `manager` can edit that account (same fields an admin can: +mobile, description, SSH key, date of birth, home directory, login shell, +and the manager list itself) without needing `app_sso_admin`. + +Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64), +verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the +canonical hasher; if you provision users out-of-band, hash passwords the same +way or use `slappasswd -h '{SSHA512}'`. + +### Groups + +Groups are `cn=,ou=groups,` (`groupOfNames`) with a `member` +attribute listing member DNs. The `memberOf` overlay populates reverse +membership (`memberOf` on the user); `refint` keeps it consistent on +add/remove. + +Note that `groupOfNames` requires **at least one member**, which has two +consequences worth knowing: whoever creates a group is automatically seeded +into it, and removing the last member (user *or* nested group) is refused with +a 409 rather than leaving an invalid entry behind. + +### Nested groups + +A `member` DN may be another group's, not just a user's — that is how nesting +is stored, with no extra schema. Everyone in the nested group is a member of +the outer one, at any depth. Manage it on the **Groups** page under each +group's *Nested* tab, or via the API: + +``` +PUT /api/group/:group/nested/:child nest :child inside :group +DELETE /api/group/:group/nested/:child un-nest +GET /api/group/:group/effective direct users, nested groups, and the + full transitive set of users +``` + +Cycles are refused (409) rather than truncated — a loop makes "who is in this +group" unanswerable. Two standing relationships are wired automatically: the +cross-app `app_super_admin` is nested into every resource's `_admin` +group, and each `_admin` into its `_access` group, so administering +something implies being able to use it. + +**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release +can evaluate nested groups; `memberOf` and a `(member=X)` filter both return +direct membership only. The bundled slapd is therefore built from source with +the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so +via `ldap.nestedGroupsServerSide`. Against any other server the app computes the +closure itself — same answers, more queries. Either way, **never read `memberOf` +directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`, +which is correct in both modes. + +### Personal groups + +Every user (person or service account) also gets a **personal Unix group** +at creation — `cn=,ou=groups,`, `objectClass: posixGroup` (RFC +2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a +different schema than the `groupOfNames` groups above — its membership +attribute is `memberUid` (a bare username, not a DN), and unlike +`groupOfNames` it's valid with zero members. It's excluded from the +`/groups` page (which filters on `objectClass=groupOfNames`) and managed +instead from the owning user's own profile page ("Members of ``'s +group", admin-only) — add other accounts as supplementary members, e.g. to +share write access to files owned by this group. + +The SSO seeds these groups automatically (entrypoint / `install.sh`): + +| Group | Grants | +|-------|--------| +| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. | +| `app_sso_admin` | full admin (users, groups, settings) | +| `app_sso_oauth_admin` | OAuth client management | +| `app_sso_invite` | invitation management | +| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. | + +## TLS (LDAPS / StartTLS) + +The bundled slapd generates a **self-signed cert** on first start (CN = +`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on: + +- `ldaps:///` — **636**, TLS (the port to expose for direct-LDAP clients). +- `ldap:///` — **389**, plain + StartTLS (not mapped to the host by default). + +The cert lives on the `ldap-certs` volume so it persists across container +recreation. + +### Trusting the self-signed cert + +Copy it out and add it to the client's CA store: + +```bash +docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt +``` + +…or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy +sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect). + +### Using your own cert + +Replace the `ldap-certs` named volume with a bind mount containing your own +`ldap.crt` + `ldap.key`: + +```yaml +volumes: + - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key +``` + +The entrypoint leaves existing certs untouched (idempotent). + +## Choosing the LDAPS hostname + +The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By +default it derives that URL from the public OAuth issuer (e.g. +`https://sso.example.com` → `ldaps://sso.example.com:636`). That is convenient, +but it implies LDAP clients reach your directory through the same public +hostname — which usually means port-forwarding 636 through your router. + +**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds +have no rate limiting and are a brute-force target. Instead, use one of these +internal-only patterns and set `conf.ldap.ldapsHost` (or +`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL. + +### 1. Same Docker / local network host (best for apps on this machine) + +If the LDAP client runs on the same Docker network as the SSO Manager (for +example, the bundled `theta-env` stack), use the internal service name: + +``` +ldaps://sso-manager:636 +``` + +In `conf/secrets.js`: + +```javascript +ldap: { + ldapsHost: 'sso-manager', + ldapsPort: 636, +} +``` + +The proxy in theta-env already uses this internally. The bundled slapd cert +includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default, +so hostname verification works without extra setup. + +### 2. LAN host behind your router (best for separate home-lan machines) + +Create an internal-only DNS record — e.g. `ldap.internal.example.com` → +`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get +or generate a cert whose SAN/CN matches that internal name: + +- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the + public domain and can complete DNS-01 challenge; the record itself can stay + private/routable only inside your LAN. +- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for + `ldap.internal.example.com`, and distribute the CA cert to clients. +- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy + the generated `ldap.crt` to each client and trust it. + +In `conf/secrets.js`: + +```javascript +ldap: { + ldapsHost: 'ldap.internal.example.com', + ldapsPort: 636, +} +``` + +The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`. + +### 3. Public hostname (acceptable only behind a VPN/firewall) + +If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard, +etc.) or a tightly locked-down firewall rule. In that case the public hostname +may be appropriate, but the LDAPS port should still not be reachable from the +open internet. + +### Why not just use the LDAP server's IP address? + +TLS clients verify the server name against the certificate. Connecting to +`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com` +will fail hostname verification unless you disable cert checks — which removes +most of the security benefit of LDAPS. Always use a hostname that matches the +cert. + +## Service accounts + +A service account is a normal `posixAccount` for something that isn't a +person: a media manager, a torrent client, a service like Emby, or a +read-only bind account an app uses to look users up — anything that needs a +real `uidNumber`/`gidNumber` to own files, or that other accounts join via a +group for write access (e.g. a `stuff_manager` group granting write rights +to a media library). There's only one kind — every account, person or +service, is a real `posixAccount` with a UID. + +Create one from the **Users → Service Accounts** tab's "Add new user" form +with **This is a service account** checked — it skips the birthday/ +Terms-of-Service fields a real person's account needs and asks for just an +account name. It's flagged (via membership in the `app_sso_service_account` +group) so it's listed separately from real people and excluded from "all +users" notification broadcasts. + +Email and password are both optional for a service account: + +- No `mail` is set unless you give it one (it never needs a mailbox). +- Leaving the password blank is fine — no `userPassword` attribute is set at + all, and an entry with no `userPassword` simply can't bind with any + password (standard LDAP simple-bind behavior). Only set a password if the + account actually needs to authenticate as itself (e.g. a bind-only account + an app uses to look users up). + +theta-env's bootstrap creates its own `cn=ldapclient` bind account directly +against LDAP (independent of this app), and the proxy binds as it — that +account won't show up in the Service Accounts tab since it isn't managed +through this app, but it keeps working unchanged. + +Either way: don't reuse the admin DN, and give a service account only the +group memberships and `manager`s it actually needs. + +Example bind test (a service account with a password set): + +```bash +ldapsearch -x -H ldaps://sso.example.com:636 \ + -D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \ + -b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail +``` + +## Connecting a 3rd-party app or container + +Most self-hosted apps with an "LDAP authentication" settings page — Gitea, +Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via +`LDAP_*` env vars, all ask for the same handful of values. These are the +`conf.ldap` values from [Configuration](configuration.html), applied to +*your* domain: + +| Field the app asks for | Value | +|---|---| +| Host / URL | `ldaps://:636` (preferred), or `ldap://:389` + StartTLS | +| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,` (see above) | +| Bind password | that service account's password | +| User search base | `ou=people,` | +| User search filter | `(objectClass=posixAccount)` | +| Username attribute | `uid` | +| Email attribute | `mail` | +| Group search base | `ou=groups,` | +| Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) | +| TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use | + +### Worked example: Gitea + +Gitea's **Admin → Authentication Sources → Add Authentication Source** (type +LDAP, "Bind DN/Password") maps directly: + +- Security Protocol: `LDAPS` +- Host / Port: your SSO host / `636` +- Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com` +- Bind Password: the service account's password +- User Search Base: `ou=people,dc=yourdomain,dc=com` +- User Filter: `(&(objectClass=posixAccount)(uid=%s))` +- Username Attribute: `uid` +- E-mail Attribute: `mail` + +Other apps with an LDAP settings UI follow the same shape — the field names +above are the constants; only the base DN and hostname change per deployment. + +### Generic Docker container (`LDAP_*` env vars) + +For images that take a flat env-var LDAP config (there's no single standard, +but most look like this): + +```yaml +environment: + LDAP_URL: ldaps://sso.example.com:636 + LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com + LDAP_BIND_PASSWORD: + LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com + LDAP_USER_FILTER: (objectClass=posixAccount) + LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com +``` + +Check the specific image's docs for its actual variable names — the values +you plug in are still the ones from the table above. + +### Full Linux host auth (SSH, sudo, login) instead of a single app + +If you want a *host* (not just one app) to authenticate logins, SSH keys, and +sudo against this LDAP directory — not just one application — that's a +different integration (SSSD + PAM + NSS, not a single bind). See +[theta42/ldap-client](https://github.com/theta42/ldap-client): a script that +configures SSSD on Ubuntu/Debian hosts against this directory, including +group-based access control and SSH public key retrieval from LDAP. + +## Modules + overlays (external LDAP servers) + +If you point the app at your own LDAP server instead of the bundled slapd, it +needs: + +- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`), + `ppolicy`, `memberof`, `refint`. +- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any + released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still + unreleased), so the bundled image builds slapd from a pinned upstream commit. + Without it the app resolves nesting itself and everything still works — leave + `ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and + configure: + + ``` + overlay nestgroup + nestgroup-base ou=groups, + nestgroup-flags member-filter memberof-filter memberof-values + ``` + + Flags are **space-separated**; the comma form the man page's `{a, b, c}` + notation suggests is rejected. `member-values` is deliberately omitted — it + expands the `member` attribute when reading a group, which destroys the + distinction between "listed here" and "reachable through a nested group", and + the raw values are then unrecoverable. Transitive answers come from the filter + flags and from `GET /api/group/:group/effective`. + + One more consequence of building from master: it ships **LMDB 1.0.0**, whose + on-disk format is mutually unreadable with the 0.9.x in 2.6.x + (`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two + is a `slapcat` → `slapadd` reload, not a restart. +- **Custom schema:** the `theta42Person` auxiliary objectClass with + `dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF. +- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, + a default `pwdPolicy` at `cn=ppolicy,ou=policies,`. +- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`, + and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint + also nests it into the first three). + +`ops/ldap-setup.sh -p ` configures all of the above +idempotently against a running slapd (auto-detects the database holding your +base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's +active/inactive toggle depends on). + +## Backups and restore + +`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention) +for standalone deployments — see the *Backups and restore* section of +`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the +hood, useful if you want just the directory without Redis/config. + +**Backup** (while slapd is running): + +```bash +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif +``` + +Store the `.ldif` off the host — it contains every user's password hash. + +**Restore** into a stopped directory. The SSO image uses a static `slapd.conf` +(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`: + +```bash +docker compose stop sso-manager +docker compose run --rm --no-deps --entrypoint sh sso-manager -c \ + 'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \ + < ldap-backup-.ldif +docker compose start sso-manager +``` + +Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. + +Redis state (OAuth clients, tokens) and `./config/` secrets are backed up +separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the +full (LDAP + Redis + secrets) runbook. + +## Troubleshooting + +### `503 OpenLDAP ppolicy overlay is not configured` + +The ppolicy overlay isn't attached to the database holding your users, so the +active/inactive toggle can't set `pwdAccountLockedTime`: + +```bash +sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com +``` + +### LDAP connection refused + +```bash +docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base' +systemctl status slapd # bare metal +``` + +[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/oauth.md b/docs/oauth.md new file mode 100644 index 0000000..d1b80b5 --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,112 @@ +--- +layout: default +title: OAuth / OIDC +description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints. +--- + +# OAuth 2.0 / OpenID Connect + +[← Back to Home](index.html) + +> Looking for a plainer explanation of clients/scopes/redirect URIs instead +> of endpoint-level detail? See +> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html). + +SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own +access, refresh, and ID tokens that your apps can consume to authenticate +users and authorize API calls. It also runs a full OpenLDAP directory, so it +can be both your SSO and your user directory at once. + +## Discovery + +The provider publishes a standards-compliant discovery document: + +``` +GET https:///.well-known/openid-configuration +``` + +It advertises the `issuer`, `authorization_endpoint`, `token_endpoint`, +`userinfo_endpoint`, `end_session_endpoint`, supported scopes, and token +lifetimes. OIDC clients (e.g. the theta42/proxy) can read their endpoint URLs +from here rather than configuring each one. + +The `issuer` advertised is `conf.oauth.issuer` — set it to the **browser-facing** +HTTPS URL the SSO is served at (e.g. `https://sso.example.com`), either in +`conf/secrets.js` or via `app_oauth__issuer` / `OAUTH_ISSUER`. + +## OAuth clients + +An OAuth client represents an app that authenticates against the SSO. Each has: + +- `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is + shown once** when the client is created or rotated — save it immediately). +- `name`, `description`, `created_by` (the admin uid that created it). +- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may + use `*` (one hostname label) / `**` (any number of labels) as a wildcard — + e.g. `https://*.example.com/__proxy_auth/callback` covers every host + theta42/proxy fronts under `example.com`, so you don't have to register + each proxied host's callback individually. +- `scopes` — requested scopes (default `openid profile email groups`). +- `allowed_groups` — restrict the client to members of specific SSO groups + (empty = any valid user). +- `token_lifetime` — `access_token` / `refresh_token` lifetimes (seconds). + +### Managing clients + +Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service. + +| Action | How to do it | +|--------|--------------| +| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. | +| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. | +| **Delete** | Click the trash can on the OAuth resource in the Directory list. | +| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. | + +> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group. + +Editing an OAuth client resource + +## Scopes + +| Scope | Claims / access | +|-------|-----------------| +| `openid` | OIDC ID token + discovery | +| `profile` | `preferred_username`, display name, etc. | +| `email` | the user's `mail` | +| `groups` | the user's group memberships (the `groups` claim) | + +The `groups` claim is what relying parties (e.g. the proxy's +`app_auth__adminGroups`) use to map group membership to roles. + +## Token lifetimes + +Defaults (overridable per-client via `token_lifetime`, or globally via +`app_oauth__token_lifetime__access_token` / +`app_oauth__token_lifetime__refresh_token`): + +- access token: 3600s (1 hour) +- refresh token: 2592000s (30 days) + +## Admin gating + +SSO admin actions are gated by LDAP group membership (checked via the group's +`member` list, not `memberOf` on the user): + +- `app_sso_admin` — full admin (users, groups, settings). +- `app_sso_oauth_admin` — OAuth client management. +- `app_sso_invite` — invitation management. + +The bootstrap in [theta-env](https://github.com/theta42/theta-env) creates your +first admin and adds them to `app_sso_admin` + `app_sso_oauth_admin` +automatically; for a standalone install, add the admin's DN to those groups +manually (or via `ops/ldap-setup.sh`). + +## JWT signing + +Tokens are signed with `conf.oauth.jwtSecret` (`app_oauth__jwtSecret` / +`JWT_SECRET`). **Persist this secret** — if it changes, every issued token +stops validating. The all-in-one Docker image auto-generates one if none is set, +but that generated value does not survive container recreation unless you +persist it (set `JWT_SECRET` in your `.env`). + +[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..4e27295 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,132 @@ +# Plugins + +The SSO Manager runs **plugins** as scheduled background tasks. A plugin +**type** is an installed module; a plugin **instance** is a configured, loadable +copy of a type. You can create, edit, load/unload, run, and delete instances +from the **Plugins** page (or the `/api/plugins` API), and you can run several +instances of the same type — e.g. two Proxmox endpoints, each with its own URL +and token on its own schedule. + +Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at +`secret/plugins//conf`, not in `sso-secrets.js`. The admin UI only +ever shows them masked (`********`); the plugin reads them at run time. This +needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy +`secret/plugins/*`); re-run `./setup.sh` after upgrading. + +## Plugin types + +A plugin type is a module under `nodejs/plugins//.js`. The +filename basename (without `.js`) is the `type`; the parent directory is the +`category`. The built-ins ship under `plugins/discovery/`: + +- `proxmox` — Proxmox VE (URL + API token) +- `unifi` — UniFi Network controller (URL + username/password) +- `nmap` — nmap OS + port scan (a target range; no credentials) + +A module exports a **manifest**: + +```javascript +module.exports = { + // Identity — `type`/`category` default to the file/dir name but can be set + // explicitly. `name`/`description` show up in the UI. + type: 'proxmox', + category: 'discovery', + name: 'Proxmox VE', + description: 'Discover VMs, containers, and nodes from a PVE endpoint.', + + // Drives the admin UI form, API validation, and secret masking. Fields with + // `secret: true` are stored in OpenBao; the rest live in the DB row. + configSchema: [ + { key: 'url', label: 'API URL', type: 'url', required: true }, + { key: 'tokenId', label: 'Token ID', type: 'text', required: true }, + { key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true } + ], + + // "Test" button: validate the config (don't do the work). Return + // { ok: true } or { ok: false, error: '...' }. Optional. + validate: async (config) => { … }, + + // The work. `run` is the generalized contract name; the discovery plugins + // also keep `discover` as an alias for back-compat. For `category: + // 'discovery'`, the scheduler passes the result to the discovery reconciler. + run: async (config) => { return { resources, edges }; }, + discover: async (config) => { return { resources, edges }; } +}; +``` + +`run(config)` receives the merged non-secret config + secret values as one flat +object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it +returns `{ resources, edges }`; the reconciler upserts them into the resource +graph attributed to the instance's **slug** (the `discovery_sources` name). + +### Writing a custom plugin type + +Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory) +following the manifest above. New types are picked up at boot, so restart the +SSO Manager after adding one. Runtime load/unload is per-**instance** only — +adding a new type still needs a restart. + +## The Plugins page + +Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin` +/ `app_super_admin`): + +- **New Plugin** — pick a type, name it, choose a unique slug (the discovery + source name + the URL the resource graph attributes results to), set a cron + schedule, and fill in the config form (secret fields are password inputs). + Creating it schedules it and kicks one immediate run. +- **Edit** — name, cron, and non-secret config. +- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a + field blank to keep its current value. +- **Test** (vial icon) — runs the plugin's `validate`. +- **Run now** (play icon) — enqueues one immediate run regardless of state. +- **Load / Unload** — enable/disable the schedule without deleting the instance. +- **Delete** — removes the schedule, the OpenBao secret namespace, and the row. + +## API + +All endpoints are mounted at `/api/plugins`, require an authenticated admin +(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return +secret values masked. + +| Method + path | Purpose | +|---|---| +| `GET /api/plugins/types` | list installed plugin types + their `configSchema` | +| `GET /api/plugins` | list instances (with masked secrets + last-run state) | +| `GET /api/plugins/:id` | one instance | +| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao | +| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config | +| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) | +| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` | +| `POST /api/plugins/:id/load` | enable + schedule + run now | +| `POST /api/plugins/:id/unload` | unschedule + disable | +| `POST /api/plugins/:id/run` | enqueue one immediate run | +| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row | +| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` | + +## Scheduler internals + +The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance +a stable JobScheduler id (`plugin:`); load/unload upsert/remove +that one schedule without disturbing the others. A daily `garbage_collect` job +prunes discovery resources not seen in > 7 days. + +### Legacy migration + +Before this system, plugins were configured statically in `sso-secrets.js`: + +```javascript +module.exports = { + discovery: { + plugins: { + proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' } + } + } +}; +``` + +On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is +empty **and** `conf.discovery.plugins` has entries, one instance per configured +type is seeded automatically (secret fields copied into OpenBao). After that the +table is non-empty and the static config is ignored — manage plugins from the +UI/API instead. The migration is idempotent (guarded by the empty-table check). \ No newline at end of file diff --git a/docs/replication.md b/docs/replication.md new file mode 100644 index 0000000..06955ad --- /dev/null +++ b/docs/replication.md @@ -0,0 +1,55 @@ +--- +layout: default +title: Geo-Location Scaling (Replication) +--- + +# Geo-Location Scaling (Replication) + +SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability. + +## Why and when to use this? +- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link. +- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast. +- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B. + +By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables. + +## How it works + +In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`). +- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C. +- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster. +- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site. + +## Configuration + +To enable replication, you must pass two environment variables to the `sso-manager` container: + +1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster. +2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster. + +### Example using `theta-env` / Docker Compose + +**Site 1 (`setup.env` or `docker-compose.yml`)** +```env +LDAP_SERVER_ID=1 +LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636" +``` + +**Site 2 (`setup.env` or `docker-compose.yml`)** +```env +LDAP_SERVER_ID=2 +LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636" +``` + +**Site 3 (`setup.env` or `docker-compose.yml`)** +```env +LDAP_SERVER_ID=3 +LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636" +``` + +Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`. + +## User Locations + +When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory. diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..9bfccd1 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml diff --git a/docs/vault.md b/docs/vault.md new file mode 100644 index 0000000..c79b7df --- /dev/null +++ b/docs/vault.md @@ -0,0 +1,38 @@ +# Vault Secrets Management + +The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled. + +## Usage + +You can access the Vault UI from the application's top navigation bar. + +### Creating Secrets + +1. Click on the **New Secret** button. +2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`). +3. Enter the **Secret Data** in JSON format. For example: + ```json + { + "username": "admin", + "password": "supersecretpassword123" + } + ``` +4. Click **Save Secret**. + +### Reading and Editing Secrets + +* To view a secret, click on its name in the **Secrets List**. +* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes. + +### OpenBao Integration + +The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao. + +## API Access + +If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode): + +```bash +# Example: Read a secret via the API +curl -H "X-Vault-Token: root" -H "Authorization: Bearer " http:///api/vault/secret/data/ +``` diff --git a/nodejs/app.js b/nodejs/app.js index 10a094e..ed612b4 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -72,7 +72,8 @@ app.locals.ui = require('./utils/ui'); // Have express server static content( images, CSS, browser JS) from the public // local folder. maxAge is short since this is the app's own JS/CSS, which // changes on every deploy and isn't cache-busted/fingerprinted. -app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'})) +app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'})); +app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'})); // Routes for front end content. app.use('/', require('./routes/index')); diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index 2461bce..36c8037 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -32,10 +32,13 @@ module.exports = { return new Promise((resolve, reject) => { // OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root). - const scan = new nmap.NmapScan(targetRange); - scan.command.push('-Pn'); - scan.command.push('-F'); // fast scan, 100 top ports - scan.command.push('--min-rate', '100'); // speed up the scan + // Pass custom arguments in constructor so node-nmap includes them before spawning nmap process. + // -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge) + // -sT: TCP connect scan (unprivileged scan compatible with container environments) + // -F: fast scan (100 top ports) + // --min-rate 100: speed up scan rate + const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100']; + const scan = new nmap.NmapScan(targetRange, customFlags); if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`); diff --git a/nodejs/public/resources/theta-agent/install.sh b/nodejs/public/resources/theta-agent/install.sh new file mode 100644 index 0000000..7a782cf --- /dev/null +++ b/nodejs/public/resources/theta-agent/install.sh @@ -0,0 +1,130 @@ +#!/bin/bash +set -e + +# --- Configuration --- +# In a real environment, these would be derived from the script's download URL +# or passed as additional arguments. For now, we use the most recent release. +BINARY_URL="${BINARY_URL:-}" +CONFIG_DIR="/etc/theta42" +CONFIG_FILE="$CONFIG_DIR/agent.yml" +BIN_PATH="/usr/local/bin/theta-agent" +SERVICE_FILE="/etc/systemd/system/theta-agent.service" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +log() { echo -e "${GREEN}[+]${NC} $1"; } +error() { echo -e "${RED}[!]${NC} $1"; exit 1; } + +# 1. Root check +if [ "$EUID" -ne 0 ]; then + error "This script must be run as root." +fi + +# 2. Argument Parsing +URL="" +TOKEN="" +B64_CONFIG="" + +while [[ $# -gt 0 ]]; do + case $1 in + --url) + URL="$2" + shift 2 + ;; + --token) + TOKEN="$2" + shift 2 + ;; + *) + B64_CONFIG="$1" + shift + ;; + esac +done + +# Validation +if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then + error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token." + echo "Usage examples:" + echo " sh install.sh \"BASE64_CONFIG\"" + echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\"" + exit 1 +fi + +# 3. Resolve binary URL dynamically if not specified +if [ -z "$BINARY_URL" ]; then + if [ -n "$URL" ]; then + BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64" + elif [ -n "$B64_CONFIG" ]; then + EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true) + if [ -n "$EXTRACTED_URL" ]; then + HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//') + BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64" + fi + fi +fi +if [ -z "$BINARY_URL" ]; then + BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64" +fi + +log "Downloading binary from $BINARY_URL..." +curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary." +chmod +x "$BIN_PATH" + +# 4. Setup configuration +log "Preparing configuration directory $CONFIG_DIR..." +mkdir -p "$CONFIG_DIR" +chmod 755 "$CONFIG_DIR" + +if [ -n "$B64_CONFIG" ]; then + log "Decoding and writing configuration from base64..." + echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration." +else + log "Generating minimal configuration from arguments..." + # Create a minimal yaml with the provided URL and Token + cat < "$CONFIG_FILE" +server_url: "$URL" +auth_token: "$TOKEN" +location: "unknown" +capabilities: + telemetry: true + configure_ldap: false + reboot: false + service_control: [] + arbitrary_bash: false +EOF +fi +chmod 600 "$CONFIG_FILE" + +# 5. Setup systemd service +log "Creating systemd service unit..." +cat < "$SERVICE_FILE" +[Unit] +Description=Theta Agent Unified Endpoint Management +After=network.target + +[Service] +Type=simple +ExecStart=$BIN_PATH +Restart=always +RestartSec=5 +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=theta-agent + +[Install] +WantedBy=multi-user.target +EOF + +# 6. Start the agent +log "Enabling and starting Theta Agent..." +systemctl daemon-reload +systemctl enable theta-agent +systemctl start theta-agent + +log "Theta Agent installation complete!" +log "Verify status with: systemctl status theta-agent" +log "Check logs with: journalctl -u theta-agent -f" diff --git a/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 b/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 new file mode 100755 index 0000000..cb9c281 Binary files /dev/null and b/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 differ diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 09abafe..9a600a7 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -1,53 +1,105 @@ 'use strict'; +const express = require('express'); +const agentManager = require('../utils/agent_manager'); + module.exports = function initAgentWebSockets(app) { - if (!app.wss) { - console.warn("WebSocket server for agents is not initialized."); - return; + if (!app.wss) { + console.warn("WebSocket server for agents is not initialized."); + return; + } + + app.wss.on('connection', (ws, req) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const token = url.searchParams.get('token') || req.headers['authorization']; + + if (!token) { + ws.close(4001, 'Unauthorized: Missing token'); + return; } - app.wss.on('connection', (ws, req) => { - // Parse the token from query param or header (e.g. ?token=XYZ) - // For the beta, we will just accept it if a token is present. - const url = new URL(req.url, `http://${req.headers.host}`); - const token = url.searchParams.get('token') || req.headers['authorization']; + const remoteAddr = req.socket.remoteAddress; + console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`); - if (!token) { - ws.close(4001, 'Unauthorized: Missing token'); - return; + agentManager.registerAgent(token, ws, remoteAddr); + + ws.on('message', (message) => { + try { + const data = JSON.parse(message); + if (!data || typeof data.type !== 'string') return; + + const payload = data.payload || {}; + + switch (data.type) { + case 'discovery': + agentManager.handleDiscovery(token, payload); + if (app.io) app.io.emit('agent.discovery', { token, payload }); + break; + case 'telemetry': + agentManager.handleTelemetry(token, payload); + if (app.io) app.io.emit('agent.telemetry', { token, payload }); + break; + case 'heartbeat': + agentManager.handleHeartbeat(token, payload, ws); + break; + case 'response': + agentManager.handleResponse(token, payload); + if (app.io) app.io.emit('agent.response', { token, payload }); + break; + default: + console.log(`[Theta Agent] Received message type '${data.type}' from ${token}`); } - - console.log(`[Theta Agent] Agent connected from ${req.socket.remoteAddress}`); - - ws.on('message', (message) => { - try { - const data = JSON.parse(message); - - // Example handling incoming telemetry - if (data.type === 'telemetry') { - // Send to discovery service or log - // console.log(`[Theta Agent] Received telemetry from ${data.host}`); - - // We can publish it to the event bus for the UI - if(app.contoller && app.contoller.ps) { - app.contoller.ps.publish('agent.telemetry', data); - } - } - } catch (err) { - console.error("[Theta Agent] Error parsing message:", err); - } - }); - - ws.on('close', () => { - console.log(`[Theta Agent] Agent disconnected`); - }); - - // Example: Send a welcome config payload to the agent - ws.send(JSON.stringify({ - type: 'config', - payload: { - message: 'Welcome to SSO Manager C2' - } - })); + } catch (err) { + console.error("[Theta Agent] Error parsing message:", err); + } }); + + ws.on('close', () => { + console.log(`[Theta Agent] Agent disconnected (${token})`); + agentManager.unregisterAgent(token, ws); + }); + + // Send initial welcome/config payload + try { + ws.send(JSON.stringify({ + type: 'config', + payload: { + message: 'Connected to SSO Manager C2', + protocol_version: '1.1.0' + } + })); + } catch (e) {} + }); + + // REST API routes for Agent Management (mounted under /api/agent) + const router = express.Router(); + + router.get('/nodes', (req, res) => { + res.json({ + status: 'ok', + agents: agentManager.getConnectedAgents(), + publicKey: agentManager.publicKeyPem + }); + }); + + router.post('/nodes/:token/command', (req, res) => { + const { token } = req.params; + const { command, payload, isHighRisk } = req.body; + + if (!command) { + return res.status(400).json({ status: 'error', message: 'Command type is required' }); + } + + try { + const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary']; + const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command); + + const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning); + res.json({ status: 'ok', sentMessage: msg }); + } catch (err) { + res.status(400).json({ status: 'error', message: err.message }); + } + }); + + app.use('/api/agent', router); }; diff --git a/nodejs/tests/agent_manager.test.js b/nodejs/tests/agent_manager.test.js new file mode 100644 index 0000000..11b227e --- /dev/null +++ b/nodejs/tests/agent_manager.test.js @@ -0,0 +1,100 @@ +'use strict'; + +const crypto = require('crypto'); +const agentManager = require('../utils/agent_manager'); + +describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => { + let mockWs; + let sentMessages; + + beforeEach(() => { + sentMessages = []; + mockWs = { + readyState: 1, // OPEN + send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))), + close: jest.fn() + }; + }); + + test('registers agent and tracks initial connection state', () => { + const record = agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100'); + expect(record.token).toBe('test-token-123'); + expect(record.ipAddress).toBe('192.168.1.100'); + + const agents = agentManager.getConnectedAgents(); + const found = agents.find(a => a.token === 'test-token-123'); + expect(found).toBeDefined(); + expect(found.isOnline).toBe(true); + }); + + test('processes discovery payload per PROTOCOL.md v1.1.0 Section 3.1', () => { + agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100'); + + const discoveryPayload = { + hostname: 'node-01.local', + ip_addresses: ['192.168.1.100', '10.0.0.5'], + os: 'Ubuntu 24.04 LTS', + kernel: '6.8.0-31-generic', + cpu: 'AMD EPYC 7763', + ram_total_gb: 32.0, + disk_total_gb: 500.0, + location: 'dc-chicago-rack-4' + }; + + agentManager.handleDiscovery('test-token-123', discoveryPayload); + + const agents = agentManager.getConnectedAgents(); + const agent = agents.find(a => a.token === 'test-token-123'); + expect(agent.hostname).toBe('node-01.local'); + expect(agent.discovery.os).toBe('Ubuntu 24.04 LTS'); + expect(agent.discovery.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']); + }); + + test('processes telemetry payload per PROTOCOL.md v1.1.0 Section 3.2', () => { + agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100'); + + const telemetryPayload = { + cpu_usage_percent: 14.5, + ram_usage_percent: 42.1, + disk_usage_percent: 68.0, + zfs_health: 'ONLINE', + gpu_usage_percent: -1.0, + timestamp: new Date().toISOString() + }; + + agentManager.handleTelemetry('test-token-123', telemetryPayload); + + const agents = agentManager.getConnectedAgents(); + const agent = agents.find(a => a.token === 'test-token-123'); + expect(agent.telemetry.cpu_usage_percent).toBe(14.5); + expect(agent.telemetry.zfs_health).toBe('ONLINE'); + }); + + test('responds to heartbeat with heartbeat_ack per Section 3.3', () => { + agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100'); + + agentManager.handleHeartbeat('test-token-123', { timestamp: new Date().toISOString() }, mockWs); + + expect(mockWs.send).toHaveBeenCalled(); + const lastMsg = sentMessages[sentMessages.length - 1]; + expect(lastMsg.type).toBe('heartbeat_ack'); + expect(lastMsg.payload.timestamp).toBeDefined(); + }); + + test('canonicalizes payload and signs high-risk commands using Ed25519 per Section 5', () => { + agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100'); + + const rawPayload = { script: 'uptime', location: 'datacenter' }; + const msg = agentManager.sendCommand('test-token-123', 'arbitrary_bash', rawPayload, true); + + expect(msg.type).toBe('arbitrary_bash'); + expect(msg.payload.signature).toBeDefined(); + expect(typeof msg.payload.signature).toBe('string'); + + // Verify signature with public key + const signatureBuffer = Buffer.from(msg.payload.signature, 'base64'); + const canonicalStr = agentManager.canonicalize(rawPayload); + const isValid = crypto.verify(null, Buffer.from(canonicalStr, 'utf8'), agentManager.publicKeyPem, signatureBuffer); + expect(isValid).toBe(true); + }); +}); diff --git a/nodejs/tests/nmap_plugin.test.js b/nodejs/tests/nmap_plugin.test.js new file mode 100644 index 0000000..fc9b178 --- /dev/null +++ b/nodejs/tests/nmap_plugin.test.js @@ -0,0 +1,46 @@ +'use strict'; + +const nmapPlugin = require('../plugins/discovery/nmap'); + +jest.mock('node-nmap', () => { + const EventEmitter = require('events'); + class MockNmapScan extends EventEmitter { + constructor(targetRange, customFlags) { + super(); + this.targetRange = targetRange; + this.customFlags = customFlags; + this.command = ['-oX', '-', ...(customFlags || []), targetRange]; + } + startScan() { + setImmediate(() => { + this.emit('complete', [ + { ip: '192.168.1.10', hostname: 'host-10', openPorts: [{ port: 80, protocol: 'tcp', service: 'http' }] } + ]); + }); + } + } + return { + NmapScan: MockNmapScan, + nmapLocation: 'nmap' + }; +}); + +describe('nmap discovery plugin', () => { + test('discover passes custom flags (-Pn, -sT, -F, --min-rate) to constructor', async () => { + const logs = []; + const result = await nmapPlugin.discover({ + targetRange: '192.168.1.0/24', + log: (msg) => { logs.push(msg); } + }); + + const startLog = logs.find(l => l.startsWith('Starting nmap scan')); + expect(startLog).toBeDefined(); + expect(startLog).toContain('-Pn'); + expect(startLog).toContain('-sT'); + expect(startLog).toContain('-F'); + expect(startLog).toContain('--min-rate 100'); + expect(result.resources).toHaveLength(2); // host + service + expect(result.resources[0].name).toBe('host-10'); + expect(result.edges).toHaveLength(1); + }); +}); diff --git a/nodejs/tests/vault_broker.test.js b/nodejs/tests/vault_broker.test.js new file mode 100644 index 0000000..c8548d7 --- /dev/null +++ b/nodejs/tests/vault_broker.test.js @@ -0,0 +1,51 @@ +'use strict'; + +jest.mock('@simpleworkjs/bao-conf', () => ({ + get: jest.fn(), + set: jest.fn(), + request: jest.fn(), +})); + +jest.mock('redis', () => ({ + createClient: () => ({ + on: jest.fn(), + connect: jest.fn().mockResolvedValue(), + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(), + }) +})); + +const baoConf = require('@simpleworkjs/bao-conf'); +const vaultBroker = require('../utils/vault_broker'); + +describe('vault_broker admin policy', () => { + beforeEach(() => { + baoConf.request.mockReset(); + }); + + test('getOrCreateAdminToken ensures sso-admin policy with list capabilities on metadata', async () => { + baoConf.request.mockImplementation(async (method, path, body) => { + if (method === 'GET' && path === 'sys/policies/acl/sso-admin') { + return { status: 404, text: async () => '' }; + } + if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') { + expect(body.policy).toContain('path "secret/metadata" { capabilities = ["list", "read", "delete"] }'); + expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }'); + return { status: 204, ok: true }; + } + if (method === 'POST' && path === 'auth/token/create/sso-broker') { + return { + ok: true, + json: async () => ({ auth: { client_token: 'test-admin-token', lease_duration: 3600 } }) + }; + } + return { status: 200, ok: true, json: async () => ({}) }; + }); + + const token = await vaultBroker.getOrCreateAdminToken('adminuser'); + expect(token).toBe('test-admin-token'); + expect(baoConf.request).toHaveBeenCalledWith('PUT', 'sys/policies/acl/sso-admin', expect.objectContaining({ + policy: expect.stringContaining('path "secret/metadata/"') + })); + }); +}); diff --git a/nodejs/utils/agent_manager.js b/nodejs/utils/agent_manager.js new file mode 100644 index 0000000..93c6f23 --- /dev/null +++ b/nodejs/utils/agent_manager.js @@ -0,0 +1,180 @@ +'use strict'; + +const crypto = require('crypto'); + +class AgentManager { + constructor() { + this.agents = new Map(); // token -> agentRecord + this.privateKeyPem = null; + this.publicKeyPem = null; + this.initKeyPair(); + } + + initKeyPair() { + try { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' } + }); + this.privateKeyPem = privateKey; + this.publicKeyPem = publicKey; + } catch (err) { + console.error('[AgentManager] Failed to generate Ed25519 key pair:', err); + } + } + + /** + * Canonicalize payload for signing per PROTOCOL.md v1.1.0 section 5: + * Sort keys alphabetically, remove whitespace, omit 'signature' key. + */ + canonicalize(payload) { + const cleanObj = {}; + const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort(); + for (const key of sortedKeys) { + cleanObj[key] = payload[key]; + } + return JSON.stringify(cleanObj); + } + + /** + * Sign payload using Ed25519 private key. + * Returns base64 encoded signature. + */ + signPayload(payload) { + if (!this.privateKeyPem) { + throw new Error('Ed25519 private key is not initialized'); + } + const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8'); + const signature = crypto.sign(null, canonicalBytes, this.privateKeyPem); + return signature.toString('base64'); + } + + registerAgent(token, ws, remoteAddress) { + const existing = this.agents.get(token); + if (existing && existing.ws && existing.ws !== ws) { + try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {} + } + + const agentRecord = { + token, + ws, + ipAddress: remoteAddress, + hostname: 'unknown', + connectedAt: new Date().toISOString(), + lastSeen: new Date().toISOString(), + discovery: {}, + telemetry: {}, + pendingResponses: new Map() + }; + + this.agents.set(token, agentRecord); + return agentRecord; + } + + unregisterAgent(token, ws) { + const record = this.agents.get(token); + if (record && record.ws === ws) { + this.agents.delete(token); + } + } + + handleDiscovery(token, payload) { + const agent = this.agents.get(token); + if (!agent) return; + + agent.lastSeen = new Date().toISOString(); + agent.hostname = payload.hostname || agent.hostname; + agent.discovery = { + hostname: payload.hostname || '', + ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [], + os: payload.os || '', + kernel: payload.kernel || '', + cpu: payload.cpu || '', + ram_total_gb: payload.ram_total_gb || 0, + disk_total_gb: payload.disk_total_gb || 0, + location: payload.location || 'default' + }; + } + + handleTelemetry(token, payload) { + const agent = this.agents.get(token); + if (!agent) return; + + agent.lastSeen = new Date().toISOString(); + agent.telemetry = { + cpu_usage_percent: payload.cpu_usage_percent || 0, + ram_usage_percent: payload.ram_usage_percent || 0, + disk_usage_percent: payload.disk_usage_percent || 0, + zfs_health: payload.zfs_health || 'N/A', + gpu_usage_percent: payload.gpu_usage_percent ?? -1, + timestamp: payload.timestamp || new Date().toISOString() + }; + } + + handleHeartbeat(token, payload, ws) { + const agent = this.agents.get(token); + if (agent) { + agent.lastSeen = new Date().toISOString(); + } + try { + ws.send(JSON.stringify({ + type: 'heartbeat_ack', + payload: { timestamp: new Date().toISOString() } + })); + } catch (e) {} + } + + handleResponse(token, payload) { + const agent = this.agents.get(token); + if (agent) { + agent.lastSeen = new Date().toISOString(); + agent.lastResponse = { + status: payload.status || 'ok', + message: payload.message || '', + output: payload.output || '', + timestamp: new Date().toISOString() + }; + } + } + + sendCommand(token, commandType, payload = {}, isHighRisk = false) { + const agent = this.agents.get(token); + if (!agent || !agent.ws || agent.ws.readyState !== 1) { + throw new Error(`Agent with token "${token}" is not connected`); + } + + const finalPayload = { ...payload }; + if (isHighRisk) { + finalPayload.signature = this.signPayload(finalPayload); + } + + const message = { + type: commandType, + payload: finalPayload + }; + + agent.ws.send(JSON.stringify(message)); + return message; + } + + getConnectedAgents() { + const list = []; + const now = new Date(); + for (const [token, agent] of this.agents.entries()) { + list.push({ + token, + hostname: agent.hostname, + ipAddress: agent.ipAddress, + connectedAt: agent.connectedAt, + lastSeen: agent.lastSeen, + discovery: agent.discovery, + telemetry: agent.telemetry, + lastResponse: agent.lastResponse || null, + isOnline: (now - new Date(agent.lastSeen)) < 90000 + }); + } + return list; + } +} + +module.exports = new AgentManager(); diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index b065c62..927f41b 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -106,10 +106,21 @@ async function getOrCreateUserToken(uid) { } // ── Admin token (read/write all of secret/) ───────────────────────────────── +function adminPolicyHcl() { + // The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST + // the KV mount root (the top-level dirs); `secret/metadata/*` covers nested + // paths but NOT the root itself, so without it the /vault secrets list 403s. + return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata" { capabilities = ["list", "read", "delete"] } +path "secret/metadata/" { capabilities = ["list", "read", "delete"] } +path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`; +} + async function getOrCreateAdminToken(uid) { const cacheKey = `vault_token:admin:${uid || 'global'}`; const cached = await cacheGet(cacheKey); if (cached) return cached; + await ensurePolicy('sso-admin', adminPolicyHcl()); const { token, ttl } = await mintToken(['sso-admin']); await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); return token; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 098f55c..4afd120 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -40,6 +40,9 @@ + @@ -1272,6 +1275,232 @@ }); } + // --- THETA AGENT INSTALL MODAL & WIZARD --- + function generateRandomHexToken(byteLen) { + const arr = new Uint8Array(byteLen || 16); + (window.crypto || window.msCrypto).getRandomValues(arr); + return Array.from(arr, b => b.toString(16).padStart(2, '0')).join(''); + } + + function regenerateAgentToken(inputId) { + const newToken = generateRandomHexToken(16); + $('#' + inputId).val(newToken); + if (inputId === 'agent-quick-token') $('#agent-custom-token').val(newToken); + else $('#agent-quick-token').val(newToken); + updateAgentCommands(); + } + + function updateAgentCommands() { + const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, ''); + const quickToken = $('#agent-quick-token').val() || ''; + const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`; + $('#agent-quick-command').text(quickCmd); + + const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, ''); + const customToken = $('#agent-custom-token').val() || ''; + const customLocation = $('#agent-custom-location').val() || 'default'; + + const telemetry = $('#cap-telemetry').is(':checked'); + const configureLdap = $('#cap-configure-ldap').is(':checked'); + const reboot = $('#cap-reboot').is(':checked'); + const arbitraryBash = $('#cap-arbitrary-bash').is(':checked'); + + const servicesRaw = $('#cap-services').val() || ''; + const servicesList = servicesRaw.split(',').map(s => s.trim()).filter(Boolean); + const servicesYaml = servicesList.length > 0 + ? '[' + servicesList.map(s => `"${s}"`).join(', ') + ']' + : '[]'; + + const yamlStr = [ + `server_url: "${customUrl}"`, + `auth_token: "${customToken}"`, + `location: "${customLocation}"`, + `capabilities:`, + ` telemetry: ${telemetry}`, + ` configure_ldap: ${configureLdap}`, + ` reboot: ${reboot}`, + ` service_control: ${servicesYaml}`, + ` arbitrary_bash: ${arbitraryBash}` + ].join('\n'); + + $('#agent-yaml-preview').text(yamlStr); + + try { + const b64Config = btoa(yamlStr); + const customCmd = `curl -fsSL ${customUrl}/resources/theta-agent/install.sh | sh -s -- "${b64Config}"`; + $('#agent-custom-command').text(customCmd); + } catch (e) { + $('#agent-custom-command').text('Error encoding config to Base64'); + } + } + + function copyAgentCommand(elementId, btnId) { + const text = $('#' + elementId).text(); + if (!text) return; + navigator.clipboard.writeText(text).then(() => { + const $btn = $('#' + btnId); + const origHtml = $btn.html(); + $btn.html(' Copied!').removeClass('btn-success').addClass('btn-outline-success'); + setTimeout(() => { + $btn.html(origHtml).removeClass('btn-outline-success').addClass('btn-success'); + }, 2000); + }).catch(err => { + app.messages.toast('Failed to copy: ' + err, 'danger'); + }); + } + + function openAgentInstallModal() { + const currentOrigin = window.location.origin; + const initialToken = generateRandomHexToken(16); + + const bodyHtml = ` +
+
+ +
+
Theta Agent Endpoint Management Daemon
+ A 2-way Command & Control (C2) daemon that streams real-time telemetry and enables secure, capability-controlled management operations on Linux hosts. +
+
+
+ + + +
+ +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+

+          
+
+ +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+
Capability Matrix (Local-First Security Controls)
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+
+ + + +
+
+

+            
+
+

+            
+
+ +
+ +
+
+
+ `; + + app.modal.open({ + title: ' Install Theta Agent', + bodyHtml: bodyHtml, + size: 'lg' + }); + + updateAgentCommands(); + } + // Plugin scheduling moved to the dedicated /plugins page (the Agents & // Scheduler tab here was its old home). Discovery inventory + the discovery // results table remain on this page.