diff --git a/README.md b/README.md index ea57c42..632abfd 100644 --- a/README.md +++ b/README.md @@ -404,36 +404,6 @@ Redis and are preserved by the volume. --- -## Running a component individually - -> The integrated stack (`./setup.sh`) is the supported path. The per-project -> commands below are for the advanced case of running one component on its own -> (separate host, no orchestrator) — you then manage secrets from the -> `config/*-secrets.js` file only (no shared OpenBao) and do the OIDC/LDAP wiring -> by hand. See [docs/standalone.md](docs/standalone.md). - -Each submodule builds and runs on its own: - -- **SSO Manager alone**: - ```bash - cd sso-manager-node - mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it - docker compose up -d --build - ``` - See its [DEPLOYMENT.md](sso-manager-node/DEPLOYMENT.md). - -- **Proxy alone** (pointing at any external SSO + LDAP via a mounted - `secrets.js`): - ```bash - cd proxy - mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it - docker compose up -d --build - ``` - See its [DEPLOYMENT.md](proxy/DEPLOYMENT.md). - -No cross-repo file edits are needed at runtime — the unified stack is pure -composition (one compose file + one bootstrap script). - --- ## How the first-run wiring works diff --git a/docs/_config.yml b/docs/_config.yml index d99df5a..8e129ea 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -28,9 +28,15 @@ nav: - title: Secrets page: /secrets.html icon: fa-key - - title: Standalone - page: /standalone.html - icon: fa-puzzle-piece + - title: SSO Manager + page: /sso/ + icon: fa-users + - title: Proxy + page: /proxy/ + icon: fa-shield-halved + - title: Jump Host + page: /jump-host/ + icon: fa-terminal - title: Changelog url: https://github.com/theta42/theta-suite/blob/master/CHANGELOG.md icon: fa-list diff --git a/docs/index.md b/docs/index.md index a71fb4e..a8d1cce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -76,7 +76,7 @@ cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any time to converge the stack to `./config/`. For the full config reference, -architecture, and running each project standalone, see the +architecture, see the **[GitHub repository](https://github.com/theta42/theta-suite)**. ## Related projects diff --git a/docs/jump-host/README.md b/docs/jump-host/README.md new file mode 100644 index 0000000..fdcfe03 --- /dev/null +++ b/docs/jump-host/README.md @@ -0,0 +1,24 @@ +# Documentation + +This directory is the GitHub Pages documentation site for the Jump Host project. + +**Live site:** https://theta42.github.io/jump-host/ + +## Pages + +- `index.md` — overview and quick start +- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP +- `architecture.md` — how auth, access resolution, key injection, and bridging work +- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL + +## Local preview + +```bash +gem install jekyll bundler +cd docs && jekyll serve +# http://localhost:4000/jump-host/ +``` + +## Updating + +Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically. diff --git a/docs/jump-host/_layouts/default.html b/docs/jump-host/_layouts/default.html new file mode 100644 index 0000000..132c524 --- /dev/null +++ b/docs/jump-host/_layouts/default.html @@ -0,0 +1,82 @@ + + + + + + + + {% seo title=false %} + {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} + + + + + + + + + +
+
+
+
+
+
+ {{ content }} +
+
+
+
+
+
+ + + + + + diff --git a/docs/jump-host/architecture.md b/docs/jump-host/architecture.md new file mode 100644 index 0000000..c5dfbf1 --- /dev/null +++ b/docs/jump-host/architecture.md @@ -0,0 +1,125 @@ +--- +layout: default +title: Architecture +description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model. +--- + +# Architecture + +The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2) +as both an SSH **server** and **client**) with two faces: the SSH front door +(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its +own — identity, authorization, and onward credentials all come from the shared +directory. + +``` + ┌────────────────────── jump host ──────────────────────┐ + ssh │ ssh2 Server (:2222) │ ssh2 Client + ─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream + user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the + │ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user) + │ 4. bridge channels ◀───────────────────────────────▶ │ + │ web UI/API (:3002) ──▶ audit + metrics (redis) │ + └───────────────────────────────────────────────────────┘ +``` + +## 1. Inbound authentication + +When a user connects, the jump host authenticates them against LDAP: + +- **Public key** — it looks up the user's `sshPublicKey` values in the directory + and matches the offered key (handling ssh2's probe-then-sign two-phase + publickey auth). The jump host's *own* injected key (identified by its comment + marker) is deliberately excluded from this match — only the jump host may hold + that private key, so accepting it inbound would be a bypass. +- **Password** — an LDAP simple bind as the user's DN. Policy is configurable: + `off` (keys only — recommended for a public host), `local` (passwords only + from loopback/RFC1918 clients, keys-only from the internet), or `all`. + +Every attempt — success or failure, with method and reason — is audited. + +## 2. Access & target resolution + +The hosts a user may reach are computed from the directory, not a local list: + +1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`). +2. For each group, the SSO's + `GET /api/discovery/resources?group=` (authenticated with an API token), + unioned and filtered to `kind: host`. + +Each host's dial address is `metadata.ip` (or the hostname from +`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached +briefly per user and shared by both the grammar path and the TUI picker. + +Target matching tries, in order: exact slug → `host_`-prefixed slug → display +name → IP → address hostname. A raw IP that isn't an accessible directory host +is refused unless explicitly allowed. + +> The directory auto-creates `_access` / `_admin` groups for every +> host and service (see the SSO's +> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html) +> docs), which is exactly what this authorization reads. + +## 3. Per-user key injection {#per-user-key-injection} + +The jump host holds **one** keypair. To connect downstream *as the user* +without asking them for anything, it must present a key the downstream `sshd` +will accept for that user. Downstream hosts (joined via +[ldap-client](https://github.com/theta42/ldap-client)) serve authorized keys +straight from LDAP via `AuthorizedKeysCommand`. So on a user's first connection, +the jump host appends its own public key to that user's `sshPublicKey` attribute +in LDAP — comment-marked so it's recognizable — then connects downstream with +its private key. + +- Idempotent: the key is added once; a redis flag skips the LDAP round-trip + afterwards. +- The jump host's bind account therefore needs **write access to the + `sshPublicKey` attribute** on user entries (an OpenLDAP ACL — see the README). + In the bundled theta-env deployment this is handled for you. +- Because the marker key is excluded from inbound auth (step 1), it grants only + the jump host's onward path, never inbound impersonation. + +## 4. Bridging + +Once the upstream connection is ready, the jump host splices SSH channels +between the two connections: + +- **shell / exec** — piped both ways, with window-change and exit-status + forwarded. +- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes; + no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work + unchanged. +- Channel requests that arrive before the upstream is ready are buffered and + replayed, so nothing is dropped during the connect. +- The downstream host key's SHA256 fingerprint is recorded in the audit event + (trust-on-use in v1). + +Byte counts per direction are tallied cheaply for the audit record. + +## Web UI, API & audit + +An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and +look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a +local anti-lockout admin (`auth.adminUsers`), with admin access gated by +`auth.adminGroups`. It exposes: + +- `GET /health` — open; `{status, activeSessions, version}` +- `GET /api/sessions` — active sessions +- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log +- `GET /api/metrics` — counters (total, failures, top users/hosts) + +Audit events and counters live in redis. Each event captures: user, auth method, +mode (grammar/picker), target slug/address/port, channel type, client IP, +success + failure reason, downstream host-key fingerprint, timing, and bytes in/out. + +## Where it sits in the stack + +- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the + OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this + jump host reads. +- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the + downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host + connects into. +- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI + under TLS. +- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together. diff --git a/docs/jump-host/assets/css/style.css b/docs/jump-host/assets/css/style.css new file mode 100644 index 0000000..e24a5de --- /dev/null +++ b/docs/jump-host/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/jump-host/assets/img/favicon.svg b/docs/jump-host/assets/img/favicon.svg new file mode 100644 index 0000000..35e1881 --- /dev/null +++ b/docs/jump-host/assets/img/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/jump-host/assets/img/theta42.svg b/docs/jump-host/assets/img/theta42.svg new file mode 100644 index 0000000..e598305 --- /dev/null +++ b/docs/jump-host/assets/img/theta42.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 42 + + diff --git a/docs/jump-host/connecting.md b/docs/jump-host/connecting.md new file mode 100644 index 0000000..fc2ccbb --- /dev/null +++ b/docs/jump-host/connecting.md @@ -0,0 +1,102 @@ +--- +layout: default +title: Connecting +description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get. +--- + +# Connecting + +You reach a downstream host two ways: name the target in your username, or log +in plain and pick it from a menu. Either way you authenticate **once**, to the +jump host, with your directory credentials. + +## The username grammar + +``` +{uid}_-_{target} +``` + +- `{uid}` — your directory username. +- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP). +- `{target}` — the host to reach: a directory **slug** (`host_web01` or just + `web01`), the host's **display name**, its **IP**, or the hostname in its + directory `address`. + +```bash +ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional) +ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach) +``` + +If the target matches a host your directory groups grant, you're bridged +straight to its `sshd` — same as if you'd SSH'd directly, but through the +audited jump host. + +## SFTP / WinSCP / scp + +Because the whole route is encoded in the username, file transfer tools that +only take one connection string work with no extra configuration: + +```bash +sftp -P 2222 alice_-_web01@jump.example.com +scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/ +``` + +**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name +to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations +(browse, upload, download, rename) work normally. + +## The interactive picker + +Log in with just your username and you get a TUI list of every host you can +reach: + +```bash +ssh alice@jump.example.com +``` + +- **↑ / ↓** move the selection +- **type** to filter the list incrementally +- **Enter** connect to the highlighted host +- **number keys** jump straight to that row +- **q** or **Ctrl-C** to quit + +Pick a host and you're bridged into it. The picker only ever lists hosts your +directory access allows — it doubles as "what can I reach from here?" + +## What you can reach + +The set of hosts is computed per login: your LDAP group memberships intersected +with the SSO directory's hosts (via the `host__access` groups the +directory auto-creates for each machine). To get access to a new host, an admin +adds you to that host's access group in the SSO — nothing on the jump host +changes. + +Targets that don't resolve to a host you're allowed to reach are refused (and +audited). Raw IPs that aren't a known directory host are denied by default. + + +## Authentication + +The jump host authenticates **you** against the directory: + +- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your + normal SSH key; the client picks it automatically. +- **Password** — your directory password (LDAP bind). Password auth is often + restricted to local networks or disabled entirely on a public jump host + (keys-only) — check with your operator. + +You never manage a separate credential for the downstream host: the jump host +handles onward authentication for you (see +[Architecture](architecture.html#per-user-key-injection)). + +## First connection to a host + +The very first time you reach a given downstream host, the jump host provisions +its access key for you behind the scenes. If that first attempt races the +directory's key-cache refresh you may see a brief + +``` +jump-host: first-time key propagation, retrying… +``` + +and it reconnects automatically. Subsequent connections are immediate. diff --git a/docs/jump-host/images/audit.png b/docs/jump-host/images/audit.png new file mode 100644 index 0000000..997974d Binary files /dev/null and b/docs/jump-host/images/audit.png differ diff --git a/docs/jump-host/images/dashboard.png b/docs/jump-host/images/dashboard.png new file mode 100644 index 0000000..8caa276 Binary files /dev/null and b/docs/jump-host/images/dashboard.png differ diff --git a/docs/jump-host/images/login.png b/docs/jump-host/images/login.png new file mode 100644 index 0000000..5881ac9 Binary files /dev/null and b/docs/jump-host/images/login.png differ diff --git a/docs/jump-host/images/sessions.png b/docs/jump-host/images/sessions.png new file mode 100644 index 0000000..e52511c Binary files /dev/null and b/docs/jump-host/images/sessions.png differ diff --git a/docs/jump-host/index.md b/docs/jump-host/index.md new file mode 100644 index 0000000..58f720f --- /dev/null +++ b/docs/jump-host/index.md @@ -0,0 +1,116 @@ +--- +layout: default +title: Home +description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to. +--- + +# Jump Host + +An SSH jump host for the [theta42](https://github.com/theta42) self-hosted +stack. Users SSH into **one** public host and land on any downstream host +they're entitled to — authenticated against the shared LDAP directory, +authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s +inventory graph, and audited end to end. + +No per-host accounts, no distributing keys, no VPN. The same people who log in +to your SSO are the people who can reach your machines — and only the machines +their directory groups grant. + +Part of the theta42 self-hosted identity stack, alongside +[SSO Manager](https://theta42.github.io/sso-manager-node/) and +[Proxy](https://theta42.github.io/proxy/), composable with one command via +[theta-env](https://theta42.github.io/theta-env/). + +## Screenshots + +Login +Dashboard +Active sessions +Audit log + +*(click any screenshot to view full size)* + + + +## Two ways to connect + +**Direct (WinSCP/SFTP-friendly):** + +```bash +ssh alice_-_web01@jump.example.com +sftp -P 2222 alice_-_web01@jump.example.com +``` + +The username grammar is `{uid}_-_{target}` — `target` is a directory host slug +(with or without the `host_` prefix), a bare hostname, or an IP. One username +string, no interactive step, so it works cleanly in WinSCP and scripts. + +**Interactive picker:** + +```bash +ssh alice@jump.example.com +``` + +A plain login shows a TUI list of the hosts you can reach; arrow-key or type to +filter, Enter to connect. + +See **[Connecting](connecting.html)** for the full usage guide. + +## Why a jump host (and why this one) + +A bastion/jump host is the standard way to give SSH access to internal machines +through a single audited entry point. What's usually painful is *authorization* +and *credentials*: who may reach which host, and how the bastion authenticates +onward without you copying keys everywhere. + +This jump host answers both from your directory: + +- **Authorization is your directory graph.** The hosts you can reach are the + union of your LDAP groups × the SSO's inventory (the `host__access` + groups the directory already auto-creates). Add someone to a group; they can + reach the host. No bastion-side allow-list to maintain. +- **Onward auth is automatic.** The jump host holds one key and injects its + public half into your `sshPublicKey` on first use, then connects downstream + **as you**. Downstream hosts already serve keys from LDAP (via + [ldap-client](https://github.com/theta42/ldap-client)'s + `AuthorizedKeysCommand`), so nothing downstream needs configuring. + +## Features + +- **Username-grammar routing** (`uid_-_target`) — straight-through to the host, + SFTP included (WinSCP works) +- **Interactive TUI host picker** on plain login, scoped to your access +- **LDAP inbound auth** — public key or password (keys-only policy recommended + for a public host) +- **Directory-driven access** — reachable hosts come from the SSO inventory, not + a static list +- **Per-user key injection** — no downstream changes, no key distribution +- **Shell, exec, and SFTP** bridging +- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable + audit log, per-user/per-host counters +- **Full audit trail** — who, target, method, result, bytes, duration, and the + downstream host-key fingerprint + +- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal + installer, or bundled in theta-env + +## Get it + +```bash +git clone https://github.com/theta42/jump-host.git +cd jump-host +cp secrets.js.example config/jump-secrets.js # then edit it +docker compose up -d --build +``` + +For bare-metal and the bundled theta-env +option, see **[Installation](installation.html)**. + +## Related projects + +- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP + directory + OIDC provider + the inventory graph this jump host reads. +- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the + same identity; fronts this jump host's web UI. +- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack, + jump host included, with one command. diff --git a/docs/proxy/README.md b/docs/proxy/README.md new file mode 100644 index 0000000..8203639 --- /dev/null +++ b/docs/proxy/README.md @@ -0,0 +1,39 @@ +# Documentation + +This directory contains the GitHub Pages documentation site for the Proxy project. + +**Live site:** https://theta42.github.io/proxy/ + +## Pages + +- `index.md` - Home page with project overview +- `installation.md` - Installation and setup guide +- `api.md` - Complete API reference +- `architecture.md` - System architecture and design +- `contributing.md` - Development and contribution guide + +## Local Preview + +To preview the site locally: + +```bash +# Install Jekyll (one-time setup) +gem install jekyll bundler + +# Run local server +cd docs +jekyll serve + +# View at http://localhost:4000/proxy/ +``` + +## Theme + +The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`. + +## Updating Documentation + +1. Edit markdown files in this directory +2. Commit and push to master branch +3. GitHub Pages automatically rebuilds (may take 1-2 minutes) +4. Changes visible at https://theta42.github.io/proxy/ diff --git a/docs/proxy/_layouts/default.html b/docs/proxy/_layouts/default.html new file mode 100644 index 0000000..132c524 --- /dev/null +++ b/docs/proxy/_layouts/default.html @@ -0,0 +1,82 @@ + + + + + + + + {% seo title=false %} + {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} + + + + + + + + + +
+
+
+
+
+
+ {{ content }} +
+
+
+
+
+
+ + + + + + diff --git a/docs/proxy/api.md b/docs/proxy/api.md new file mode 100755 index 0000000..a497b71 --- /dev/null +++ b/docs/proxy/api.md @@ -0,0 +1,972 @@ +--- +layout: default +title: API Reference +description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions. +--- + +# API Documentation + +[← Back to Home](index.html) + +All API endpoints require authentication unless otherwise noted. Three +authentication methods are supported: + +- **`auth-token` header** — a browser-session token from `POST /api/auth/login` + or the OIDC flow (below). +- **`Authorization: Bearer ` header** — a self-service API token (PAT, + see [API Tokens](#api-tokens)), for scripts/CI without a browser session. +- **OIDC (browser)** — if the proxy is configured as an OIDC client of an SSO + (`app_oidc__*` / `conf.oidc`, see [DEPLOYMENT.md](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)), + users can log in via `GET /api/auth/oidc/start` instead of posting a + username/password. + +The proxy can also be configured as a **direct LDAP client** (`app_ldap__*` / +`conf.ldap`) for looking up/validating users, independent of the OIDC flow — +see DEPLOYMENT.md for the full configuration reference. + +Authenticated requests also carry **RBAC** (role-based access control): +global admins can manage everything; other users are scoped to `viewer` or +`manager` rights on specific domains via [Permissions](#permissions) and +[Groups](#groups). + +Base URL: `https://your-proxy-host.com/api` + +--- + +## Authentication + +### Login + +**POST** `/api/auth/login` + +Authenticate a user and receive an auth token. + +```bash +curl -H "Content-Type: application/json" \ + -X POST \ + -d '{"username": "myuser", "password": "mypassword"}' \ + https://proxy-host.com/api/auth/login +``` + +**Responses:** +- `200` `{"login": true, "token": "027d3964-7d81-4462-a6f9-2c1f9b40b4be", "message": "myuser logged in!"}` +- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` + +### Logout + +**ALL** `/api/auth/logout` + +Invalidate the current auth token. + +```bash +curl -H "auth-token: your-token-here" \ + -X POST \ + https://proxy-host.com/api/auth/logout +``` + +**Responses:** +- `200` `{"message": "Bye"}` + +### OIDC Login (start) + +**GET** `/api/auth/oidc/start` + +Begin the OIDC authorization-code flow: creates a PKCE + state challenge and +redirects the browser to the configured SSO's authorize endpoint. Only +available when `conf.oidc.enabled` is true. + +**Query Parameters:** +- `redirect` - Internal path to return to after login (optional; sanitized to same-origin) + +```bash +curl -i "https://proxy-host.com/api/auth/oidc/start?redirect=/hosts" +``` + +**Responses:** +- `302` Redirect to the SSO's authorization endpoint +- `404` `{"name": "OidcDisabled", "message": "OIDC login is not enabled."}` + +### OIDC Callback + +**GET** `/api/auth/oidc/callback` + +Redirect target for the SSO after login. Validates the one-time `state`, +exchanges the authorization `code` for tokens, reads identity from the +userinfo endpoint, establishes a session, and redirects the browser back to +the login page with the app's own `auth-token` in a URL fragment. + +**Query Parameters:** +- `code` (required) - Authorization code from the SSO +- `state` (required) - State value from the `start` step + +```bash +# Not called directly — the SSO redirects the browser here after login. +``` + +**Responses:** +- `302` Redirect to `/login#token=...&redirect=...` +- `400` `{"name": "OidcCallbackInvalid", "message": "Missing code or state."}` or expired/unknown state + +--- + +## API Tokens + +Self-service personal access tokens (PATs) for scripting/CI without a browser +session. Every endpoint is owner-scoped: a user only sees/manages tokens they +created. Mounted at `/api/api-token`. + +### List API Tokens + +**GET** `/api/api-token` + +List the current user's API tokens. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/api-token +``` + +**Responses:** +- `200` `{"results": [{"id": "...", "name": "ci", ...}, ...]}` + +### Create API Token + +**POST** `/api/api-token` + +Create a new API token. The raw token string is only returned once, at +creation. + +**Parameters:** +- `name` (required) - Display name +- `description` (optional) +- `expires_in_days` (optional) - `0` or omitted means no expiry + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"name": "ci", "expires_in_days": 90}' \ + https://proxy-host.com/api/api-token +``` + +**Responses:** +- `200` `{"results": {...}, "token": "prx__", "message": "API token 'ci' created. Save it now — it will not be shown again."}` + +### Get API Token + +**GET** `/api/api-token/:id` + +Get a token's metadata (not the raw secret, which is never stored/returned again). + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/api-token/ +``` + +**Responses:** +- `200` `{"results": {...}}` +- `403` Not your token + +### Update API Token + +**PUT** `/api/api-token/:id` + +Update a token's name/description/expiry. + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X PUT \ + -d '{"name": "ci-updated"}' \ + https://proxy-host.com/api/api-token/ +``` + +**Responses:** +- `200` `{"results": {...}, "message": "API token 'ci-updated' updated."}` + +### Delete (Revoke) API Token + +**DELETE** `/api/api-token/:id` + +Revoke a token immediately. + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/api-token/ +``` + +**Responses:** +- `200` `{"id": "", "message": "API token 'ci' revoked."}` + +### Rotate API Token + +**POST** `/api/api-token/:id/rotate` + +Issue a new secret for an existing token (same id, new raw value shown once). + +```bash +curl -H "auth-token: your-token-here" \ + -X POST \ + https://proxy-host.com/api/api-token//rotate +``` + +**Responses:** +- `200` `{"token": "prx__", "message": "API token 'ci' rotated. Save it — it will not be shown again."}` + +--- + +## Users + +All user endpoints require authentication. `GET /me` and `PUT /password` +(self-service) work for any authenticated user; everything else (listing, +creating, deleting users, resetting another user's password) requires global +admin. + +### List Users + +**GET** `/api/user` + +Get list of all users. Admin only. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/user +``` + +**Query Parameters:** +- `detail` - Include full user details (optional) + +**Responses:** +- `200` `{"results": ["user1", "user2"]}` +- `200` `{"results": [{"username": "user1", ...}, ...]}` (with `?detail=true`) +- `403` Not an admin + +### Get Current User + +**GET** `/api/user/me` + +Get the currently authenticated user's identity and effective RBAC rights +(drives the web UI's nav/button gating). + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/user/me +``` + +**Responses:** +- `200` `{"username": "myuser", "groups": [...], "localGroups": [...], "externalGroups": [...], "isAdmin": false, "global": null, "domains": {...}}` + +### Create User + +**POST** `/api/user` + +Create a new local user. Admin only. + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"username": "newuser", "password": "newpassword"}' \ + https://proxy-host.com/api/user +``` + +**Responses:** +- `200` User created successfully +- `403` Not an admin +- `409` Username already exists +- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error (also returned for weak passwords) + +### Delete User + +**DELETE** `/api/user/:username` + +Delete a user account. Admin only. + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/user/olduser +``` + +**Responses:** +- `200` `{"username": "olduser", "results": ...}` +- `403` Not an admin +- `404` User not found + +### Change Password (Self) + +**PUT** `/api/user/password` + +Change the password for the currently authenticated user. + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X PUT \ + -d '{"password": "newpassword"}' \ + https://proxy-host.com/api/user/password +``` + +**Responses:** +- `200` `{"results": ...}` Password changed successfully +- `422` Weak password rejected by the password policy + +### Change Password (Other User) + +**PUT** `/api/user/password/:username` + +Change the password for another user. Admin only. + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X PUT \ + -d '{"password": "newpassword"}' \ + https://proxy-host.com/api/user/password/otheruser +``` + +**Responses:** +- `200` `{"results": ...}` Password changed successfully +- `403` Not an admin +- `404` User not found + +--- + +## Permissions + +RBAC: grants a `viewer` or `manager` role to a user or group, either globally +or scoped to one domain. Global-admin-only. Mounted at `/api/permission`. + +### List Permissions + +**GET** `/api/permission` + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/permission +``` + +**Responses:** +- `200` `{"results": [{"id": "...", "subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com", ...}, ...]}` + +### List Permission Subjects + +**GET** `/api/permission/subjects` + +Autocomplete source for the "Subject" field: known usernames plus known group +names (local groups, groups already used in permissions, and groups from +`conf.auth.adminGroups` / `conf.auth.groupRoleMap`). + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/permission/subjects +``` + +**Responses:** +- `200` `{"users": ["alice", "bob"], "groups": ["ops", "sre"]}` + +### Create Permission + +**POST** `/api/permission` + +Grant a role to a subject. + +**Parameters:** +- `subjectType` (required) - `user` or `group` +- `subject` (required) - username or group name +- `role` (required) - `viewer` or `manager` +- `scope` (required) - `global` or `domain` +- `domain` (required if `scope` is `domain`) + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com"}' \ + https://proxy-host.com/api/permission +``` + +**Responses:** +- `200` `{"message": "Granted manager to user \"alice\" on example.com.", ...}` +- `422` Validation error + +### Delete Permission + +**DELETE** `/api/permission/:id` + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/permission/ +``` + +**Responses:** +- `200` `{"message": "Permission removed."}` + +--- + +## Groups + +Local groups (independent of any SSO/LDAP groups) used as subjects for +permission grants. Global-admin-only. Mounted at `/api/group`. + +### List Groups + +**GET** `/api/group` + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/group +``` + +**Responses:** +- `200` `{"results": [{"name": "ops", "members": ["alice", "bob"], ...}, ...]}` + +### Create Group + +**POST** `/api/group` + +**Parameters:** +- `name` (required) +- `members` (optional) - array of usernames + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"name": "ops", "members": ["alice"]}' \ + https://proxy-host.com/api/group +``` + +**Responses:** +- `200` `{"message": "Group \"ops\" created.", ...}` + +### Delete Group + +**DELETE** `/api/group/:name` + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/group/ops +``` + +**Responses:** +- `200` `{"message": "Group \"ops\" removed."}` + +### Add Group Member + +**POST** `/api/group/:name/members` + +**Parameters:** +- `username` (required) + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"username": "bob"}' \ + https://proxy-host.com/api/group/ops/members +``` + +**Responses:** +- `200` `{"message": "Added \"bob\" to \"ops\".", ...}` + +### Remove Group Member + +**DELETE** `/api/group/:name/members/:username` + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/group/ops/members/bob +``` + +**Responses:** +- `200` `{"message": "Removed \"bob\" from \"ops\".", ...}` + +--- + +## Hosts + +Manage proxy host configurations. + +### List Hosts + +**GET** `/api/host` + +Get list of all configured hosts. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/host +``` + +**Query Parameters:** +- `detail` - Include full host details (optional) + +**Responses:** +- `200` `{"results": ["example.com", "*.wildcard.com"]}` +- `200` `{"results": [{"host": "example.com", "ip": "192.168.1.10", ...}, ...]}` (with `?detail=true`) + +### Get Host + +**GET** `/api/host/:host` + +Get configuration for a specific host. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/host/example.com +``` + +**Responses:** +- `200` `{"item": "example.com", "results": {"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, ...}}` +- `404` `{"name": "HostNotFound", "message": "Host does not exists"}` + +### Lookup Host + +**GET** `/api/host/lookup/:domain` + +Test the host lookup algorithm (supports wildcard matching). + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/host/lookup/sub.example.com +``` + +**Responses:** +- `200` `{"string": "sub.example.com", "results": {"host": "*.example.com", ...}}` +- `200` `{"string": "sub.example.com", "results": null}` (no match) + +### Get Lookup Tree + +**GET** `/api/host/lookupobj` + +Get the internal lookup tree structure (for debugging). + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/host/lookupobj +``` + +**Responses:** +- `200` `{"results": {"com": {"example": {...}}}}` + +### Create Host + +**POST** `/api/host` + +Add a new host configuration. + +**Parameters:** +- `host` (required) - Domain name (e.g., `example.com`, `*.example.com`) +- `ip` (required) - Target IP address or FQDN +- `targetPort` (required) - Target port number (1-65535) +- `forcessl` (optional) - Force HTTPS redirect (default: true) +- `targetssl` (optional) - Use HTTPS to backend (default: false) +- `challengeType` (optional) - For wildcards: `DNS-01-wildcard` or `wildcardChild` + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, "forcessl": true, "targetssl": false}' \ + https://proxy-host.com/api/host +``` + +**Responses:** +- `200` `{"message": "\"example.com\" added.", "host": "example.com", ...}` +- `409` `{"name": "HostNameUsed", "message": "Host already exists"}` +- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error + +### Update Host + +**PUT** `/api/host/:host` + +Update an existing host configuration. + +**Parameters:** Same as Create Host (all optional) + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X PUT \ + -d '{"ip": "192.168.1.20", "targetPort": 9000}' \ + https://proxy-host.com/api/host/example.com +``` + +**Responses:** +- `200` `{"message": "\"example.com\" updated.", ...}` +- `404` `{"name": "HostNotFound", "message": "Host does not exists"}` +- `422` Validation error + +### Delete Host + +**DELETE** `/api/host/:host` + +Remove a host configuration. + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/host/example.com +``` + +**Responses:** +- `200` `{"message": "example.com deleted", ...}` +- `404` `{"name": "HostNotFound", "message": "Host does not exists"}` + +### Clear Host Cache + +**DELETE** `/api/host/cache` + +Remove all cached wildcard-subdomain host lookups. Cache entries are created on +demand when a wildcard host serves a subdomain; clearing them forces the next +request for each subdomain to be resolved fresh through the lookup tree. +Admin only. + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/host/cache +``` + +**Responses:** +- `200` `{"message": "Cleared 3 cached hosts.", "count": 3}` + +### Renew Wildcard Certificate + +**PUT** `/api/host/:host/renew` + +Manually trigger wildcard certificate renewal. + +```bash +curl -H "auth-token: your-token-here" \ + -X PUT \ + https://proxy-host.com/api/host/*.example.com/renew +``` + +**Responses:** +- `200` `{"message": "Requesting wildcard cert for *.example.com"}` +- `404` Host not found + +--- + +## DNS Providers + +Manage DNS provider integrations for wildcard SSL certificates. + +### List DNS Providers + +**GET** `/api/dns` + +Get list of configured DNS providers. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/dns +``` + +**Query Parameters:** +- `detail` - Include full provider details (optional) + +**Responses:** +- `200` `{"results": ["provider-id-1", "provider-id-2"]}` + +### List Available Provider Types + +**OPTIONS** `/api/dns` + +Get list of supported DNS provider types and their configuration requirements. + +```bash +curl -H "auth-token: your-token-here" \ + -X OPTIONS \ + https://proxy-host.com/api/dns +``` + +**Responses:** +- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}` + +### Create DNS Provider + +**POST** `/api/dns` + +Configure a new DNS provider. + +**Cloudflare:** +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"name": "My Cloudflare", "dnsProvider": "Cloudflare", "token": "your-api-token"}' \ + https://proxy-host.com/api/dns +``` + +**DigitalOcean:** +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"name": "My DO", "dnsProvider": "DigitalOcean", "token": "your-api-token"}' \ + https://proxy-host.com/api/dns +``` + +**PorkBun:** +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"name": "My PorkBun", "dnsProvider": "PorkBun", "apiKey": "pk_xxx", "secretApiKey": "sk_xxx"}' \ + https://proxy-host.com/api/dns +``` + +**DuckDNS (free):** +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \ + https://proxy-host.com/api/dns +``` + +`subdomains` is a comma-separated list of the subdomains you've registered at +[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for +`myhost.duckdns.org`), since DuckDNS has no API to list them for you. +DuckDNS only supports one A/AAAA record and one TXT record per domain (no +arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs. + +**Responses:** +- `200` `{"message": "\"provider-id\" added.", ...}` +- `422` Validation error or invalid API credentials + +### Get DNS Provider + +**GET** `/api/dns/:id` + +Get a specific DNS provider configuration. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/dns/provider-id +``` + +**Responses:** +- `200` `{"item": "provider-id", "results": {...}}` +- `404` Provider not found + +### Update DNS Provider + +**PUT** `/api/dns/:id` + +Update DNS provider configuration. + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X PUT \ + -d '{"name": "Updated Name"}' \ + https://proxy-host.com/api/dns/provider-id +``` + +**Responses:** +- `200` `{"message": "\"provider-id\" updated.", ...}` +- `404` Provider not found + +### Delete DNS Provider + +**DELETE** `/api/dns/:id` + +Remove a DNS provider and all associated domains. + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/dns/provider-id +``` + +**Responses:** +- `200` `{"message": "provider-id deleted", ...}` +- `404` Provider not found + +### List Domains + +**GET** `/api/dns/domain` + +List all domains from all configured providers. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/dns/domain +``` + +**Query Parameters:** +- `detail` - Include full domain details (optional) + +**Responses:** +- `200` `{"results": ["example.com", "test.com"]}` + +### Get Domain + +**GET** `/api/dns/domain/:domain` + +Get details for a specific domain. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/dns/domain/example.com +``` + +**Responses:** +- `200` `{"results": [{"domain": "example.com", "zoneId": "...", ...}]}` +- `404` Domain not found + +### Refresh Domains + +**POST** `/api/dns/domain/refresh/:providerId` + +Refresh the domain list from a DNS provider's API. + +```bash +curl -H "auth-token: your-token-here" \ + -X POST \ + https://proxy-host.com/api/dns/domain/refresh/provider-id +``` + +**Responses:** +- `200` `{"results": ...}` Updated domain list +- `404` Provider not found + +### Dynamic DNS + +A-records kept automatically pointed at this box's public (WAN) IP. All +`/api/dns/dynamic*` routes are viewer/manager scoped to the record's domain +(via [Permissions](#permissions)), not admin-only like the rest of `/api/dns`. + +#### Get Current Public IP + +**GET** `/api/dns/dynamic/ip` + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/dns/dynamic/ip +``` + +**Responses:** +- `200` `{"ip": "203.0.113.5"}` + +#### List Dynamic Records + +**GET** `/api/dns/dynamic` + +Lists records the caller may view (their own/granted domains, or all for admins). + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/dns/dynamic +``` + +**Responses:** +- `200` `{"results": [{"id": "...", "domain": "example.com", "name": "home", "last_status": "ok", ...}, ...]}` + +#### Create Dynamic Record + +**POST** `/api/dns/dynamic` + +Requires `manager` rights on the target domain. Applies the record immediately +against the current public IP (best-effort — failures are recorded in +`last_status` and retried by the scheduler). + +**Parameters:** +- `domain` (required) +- `name` (required) - sub-label, or `@` for the apex + +```bash +curl -H "Content-Type: application/json" \ + -H "auth-token: your-token-here" \ + -X POST \ + -d '{"domain": "example.com", "name": "home"}' \ + https://proxy-host.com/api/dns/dynamic +``` + +**Responses:** +- `200` `{"message": "\"home.example.com\" added.", ...}` +- `403` Missing `manager` rights on the domain +- `422` Validation error + +#### Refresh Dynamic Record + +**POST** `/api/dns/dynamic/:id/refresh` + +Force an immediate refresh of one record against the current public IP. +Requires `manager` rights on the record's domain. + +```bash +curl -H "auth-token: your-token-here" \ + -X POST \ + https://proxy-host.com/api/dns/dynamic//refresh +``` + +**Responses:** +- `200` `{"message": "Refreshed \"home.example.com\".", "result": {...}}` +- `403` Missing `manager` rights on the domain + +#### Delete Dynamic Record + +**DELETE** `/api/dns/dynamic/:id` + +Stop managing a record. Requires `manager` rights on the record's domain. +Leaves the provider's A record in place at its last value. + +```bash +curl -H "auth-token: your-token-here" \ + -X DELETE \ + https://proxy-host.com/api/dns/dynamic/ +``` + +**Responses:** +- `200` `{"message": "home.example.com removed.", ...}` +- `403` Missing `manager` rights on the domain + +--- + +## Certificates + +Retrieve SSL certificate information. + +### Get Certificate + +**GET** `/api/cert/:host` + +Get the SSL certificate for a host. + +```bash +curl -H "auth-token: your-token-here" \ + https://proxy-host.com/api/cert/example.com +``` + +**Responses:** +- `200` Certificate data including `cert_pem`, `fullchain_pem`, `privkey_pem`, expiry information +- `404` Certificate not found + +--- + +## Error Responses + +All endpoints may return the following error responses: + +- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` - Authentication required or invalid +- `404` `{"name": "NotFound", "message": "..."}` - Resource not found +- `422` `{"name": "ObjectValidateError", "message": [...], "keys": [...]}` - Validation errors +- `500` Internal server error + +## Notes + +- All timestamps are in milliseconds since epoch +- Authenticated endpoints accept either the `auth-token` header (browser + session / OIDC login) or an `Authorization: Bearer ` API token +- Host names support wildcards: `*` (single level) and `**` (multi-level) +- DNS providers are validated on creation - invalid API credentials will be rejected +- Wildcard certificates are automatically renewed 30 days before expiration diff --git a/docs/proxy/architecture.md b/docs/proxy/architecture.md new file mode 100644 index 0000000..29f7a04 --- /dev/null +++ b/docs/proxy/architecture.md @@ -0,0 +1,291 @@ +--- +layout: default +title: Architecture +description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together. +--- + +# Architecture + +[← Back to Home](index.html) + +> Looking for a plainer explanation of hosts, HTTPS, or the local +> permission model instead of internals? See +> [Hosts & HTTPS](concepts-hosts.html) and +> [Users, Groups & Permissions](concepts-access.html). + +## System Overview + +The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Internet │ +└─────────────────────────┬────────────────────────────────────┘ + │ HTTPS/HTTP + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ OpenResty/Nginx │ +│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │ +│ │ SSL Termination│ │ Host Routing │ │ Request Proxying│ │ +│ │ (lua-resty- │ │ (targetinfo. │ │ │ │ +│ │ auto-ssl) │ │ lua) │ │ │ │ +│ └────────────────┘ └──────┬───────┘ └─────────────────┘ │ +└────────────┬──────────────────┼───────────────────────────┬──┘ + │ │ │ + Let's Encrypt 1. Check Redis FIRST Backend + HTTP-01 2. Unix Socket (fallback) Services + │ │ │ + ▼ ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────┐ +│ Redis │ │ Node.js Application │ +│ (Primary Cache) │ │ ┌──────────────┐ ┌─────────┐ │ +│ - Host configs ◄────┼──┼──┤ Services │ │ Routes │ │ +│ - User accounts │ │ │ - host_lookup│ │ - /api/*│ │ +│ - SSL certs │ │ │ - scheduler │ │ │ │ +│ - Auth tokens │ │ └──────────────┘ └─────────┘ │ +└──────────────────────┘ └─────────┬────────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ DNS Providers │ + │ - Cloudflare │ + │ - DigitalOcean │ + │ - PorkBun │ + │ - DuckDNS (free) │ + │ (DNS-01 challenges) │ + └──────────────────────┘ +``` + +## Component Details + +### OpenResty/Nginx (Frontend) + +**Responsibilities:** +- Accept incoming HTTP/HTTPS requests +- SSL termination using lua-resty-auto-ssl +- Host-based routing decisions (Redis-first lookup) +- Proxy requests to backend services + +**Key Features:** +- HTTP-01 ACME challenge handling for automatic SSL +- Redis-first host lookup with Node.js fallback via Unix socket +- High-performance event-driven architecture +- Support for WebSocket connections +- Continues serving cached hosts even if Node.js is down + +**Configuration Files:** +- `/etc/openresty/nginx.conf` - Main configuration +- `/etc/openresty/autossl.conf` - Let's Encrypt integration +- `/etc/openresty/sites-enabled/000-proxy` - Proxy configuration +- `/usr/local/openresty/lualib/targetinfo.lua` - Host lookup module + +### Node.js Application (Backend) + +**Responsibilities:** +- API for host/user/DNS management +- Wildcard SSL certificate orchestration +- Host lookup tree maintenance +- User authentication and authorization + +**Directory Structure:** +``` +nodejs/ +├── bin/www # Application entry point +├── conf/ # Configuration (base.js, environment overlays, secrets.js) +├── controller/ # App-level wiring (pubsub, startup) +├── migrations/ # One-off Redis data migration scripts +├── models/ # Data models +│ ├── host.js # Host configuration and lookup +│ ├── auth.js # Authentication logic +│ ├── user.js # User management +│ └── dns_provider/ # DNS provider implementations +├── routes/ # API endpoints +│ ├── host.js # Host CRUD operations +│ ├── dns.js # DNS provider management +│ ├── user.js # User management +│ ├── auth.js # Authentication (login + OIDC) +│ ├── permission.js # RBAC permission management +│ ├── group.js # Local group management +│ └── api_token.js # Self-service API (PAT) tokens +├── services/ # Background services +│ ├── host_lookup.js # Unix socket server +│ └── host_scheduler.js # Cert renewal scheduler +├── middleware/ # Express middleware +│ └── auth.js # Authentication middleware +└── utils/ # Utility modules + └── unix_socket_json.js # Unix socket server +``` + +### Redis (Data Store) + +**ORM:** [model-redis](https://www.npmjs.com/package/model-redis) - A lightweight Redis ORM for Node.js with schema validation, relationships, and automatic key management. + +**Stored Data:** +- Host configurations (domain, IP, port, SSL settings) +- User accounts and hashed passwords +- Authentication tokens +- SSL certificates (for wildcard domains) +- DNS provider credentials +- Domain-to-provider mappings + +**Key Prefixes:** +``` +proxy_Host_ # Host configuration +proxy_User_ # User account +proxy_AuthToken_ # Auth tokens +proxy_DnsProvider_ # DNS provider +proxy_Domain_ # Domain info +:latest # SSL certificate cache +``` + +## Request Flow + +### Standard HTTP/HTTPS Request + +1. **Client** sends HTTPS request to `app.example.com` +2. **OpenResty** receives request, terminates SSL +3. **Lua script** (`targetinfo.lua`) queries **Redis first** for host config +4. If **found in Redis**, jump to step 7 (Node.js not involved) +5. If **not in Redis**, Lua queries Node.js via Unix socket as fallback +6. **Node.js** performs host lookup (supports wildcards), caches result in Redis +7. **OpenResty** proxies request to backend service using target IP and port +8. **Response** proxied back to client + +**Resilience**: If Node.js goes down, all hosts already cached in Redis continue to work. Only new/uncached hosts will fail until Node.js recovers. + +### Wildcard SSL Certificate Request + +1. **User** creates wildcard host (`*.example.com`) via API +2. **Node.js** validates domain has DNS provider configured +3. **Let's Encrypt** DNS-01 challenge initiated +4. **DNS provider** API creates TXT record (`_acme-challenge.example.com`) +5. **Let's Encrypt** validates TXT record +6. **Certificate** generated and stored in Redis +7. **DNS provider** cleans up TXT record +8. **Background scheduler** monitors expiration, renews 30 days before expiry + +## Host Lookup Algorithm + +The lookup tree enables sophisticated domain matching: + +``` +Input: "api.v1.example.com" + +Tree Structure: +{ + "com": { + "example": { + "*": { // Matches api.example.com + "#record": {...} + }, + "v1": { + "api": { // Matches api.v1.example.com (exact) + "#record": {...} + } + } + } + } +} + +Priority: Exact > Single wildcard (*) > Double wildcard (**) +``` + +**Wildcard Types:** +- `example.com` - Exact match only +- `*.example.com` - Matches `sub.example.com` (single level) +- `**.example.com` - Matches any depth (`sub.deep.example.com`) +- `api.*.example.com` - Matches `api.v1.example.com`, `api.v2.example.com` + +## Security Architecture + +### Authentication Flow + +1. User sends credentials to `/api/auth/login` +2. Credentials validated against stored hash (bcrypt) +3. Token generated and stored in Redis with TTL +4. Token returned to client +5. Subsequent requests include token in `auth-token` header +6. Middleware validates token before processing request + +### SSL Certificate Security + +- **Private keys** stored only in Redis (memory/disk based on config) +- **Fallback certificates** used when SNI unavailable +- **Let's Encrypt** rate limiting respected +- **DNS provider credentials** marked as `isPrivate` (not returned in API) + +### Unix Socket Communication + +- Socket file: `/var/run/proxy_lookup.socket` +- Permissions: `777` (container-safe, single-use deployment) +- Protocol: JSON over Unix stream socket +- Buffer handling: Accumulates partial messages until complete JSON + +## Performance Optimizations + +### Caching Strategy + +The system uses a multi-tier caching approach: + +1. **Redis (L1 Cache)** - OpenResty checks Redis FIRST for every request + - Primary host configuration storage + - Survives Node.js restarts/failures + - Shared across all OpenResty workers + +2. **Node.js Lookup Tree (L2 Cache)** - In-memory host lookup with wildcard matching + - Only queried when Redis has no entry + - Rebuilt automatically when hosts change + - Supports complex wildcard resolution + +3. **Wildcard Parent Caching** - Resolved wildcard matches stored back to Redis + - Subsequent requests to `api.example.com` hit Redis directly + - No repeated wildcard resolution needed + +### Unix Socket vs HTTP API + +Unix socket chosen over HTTP for host lookups: +- **Lower latency** - No TCP overhead +- **Higher throughput** - No HTTP parsing +- **Simpler** - Direct JSON communication +- **Secure** - Filesystem permissions, no network exposure + +## Scalability Considerations + +### Current Architecture + +- **Single instance** - OpenResty + Node.js + Redis on one server +- **Vertical scaling** - Add CPU/RAM as needed +- **Limitations** - Unix socket ties OpenResty to Node.js on same host + +### Future Scaling Options + +- **Redis cluster** - Distribute data storage +- **Multiple OpenResty instances** - Load balance incoming requests +- **Stateless Node.js** - Run multiple API instances +- **Replace Unix socket** - Use TCP/HTTP for cross-host communication +- **Separate cert management** - Dedicated service for wildcard SSL + +## Monitoring and Observability + +### Logs + +- **OpenResty**: `/var/log/nginx/access.log`, `/var/log/nginx/error.log` +- **Node.js**: `journalctl -u proxy.service` +- **Redis**: `redis-cli MONITOR` + +### Health Checks + +- Node.js API: `curl http://localhost:3000/api/host` +- Redis: `redis-cli PING` +- OpenResty: `systemctl status openresty` +- Unix socket: `ls -la /var/run/proxy_lookup.socket` + +### Metrics to Monitor + +- Request rate and response times +- SSL certificate expiration dates +- Redis memory usage +- Host lookup cache hit rate +- Background service execution times + +[← Back to Home](index.html) diff --git a/docs/proxy/assets/css/style.css b/docs/proxy/assets/css/style.css new file mode 100644 index 0000000..e24a5de --- /dev/null +++ b/docs/proxy/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/proxy/assets/img/favicon.svg b/docs/proxy/assets/img/favicon.svg new file mode 100644 index 0000000..35e1881 --- /dev/null +++ b/docs/proxy/assets/img/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/proxy/assets/img/theta42.svg b/docs/proxy/assets/img/theta42.svg new file mode 100644 index 0000000..e598305 --- /dev/null +++ b/docs/proxy/assets/img/theta42.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 42 + + diff --git a/docs/proxy/concepts-access.md b/docs/proxy/concepts-access.md new file mode 100644 index 0000000..76c9aa8 --- /dev/null +++ b/docs/proxy/concepts-access.md @@ -0,0 +1,75 @@ +--- +layout: default +title: Users, Groups & Permissions +description: A plain-language guide to local admin accounts, groups, and the domain-scoped permission model in theta42/proxy. +--- + +# Users, Groups & Permissions + +This page explains, in plain language, who can manage what in this app. For +the deeper system-design detail, see [Architecture](architecture.html). + +## Two different ways to log in + +Most people who use apps you've proxied through this app never see this +app's own login at all — they use whatever authentication you set up on +the *individual host* (basic auth, or single sign-on through your SSO +Manager). This page is about a different, smaller group: the people who +manage the proxy itself — adding hosts, registering DNS providers, and so +on. + +There are two ways someone gets into the proxy's own management UI: + +- **A local account**, created on the **Users** page — a username and + password specific to this app. +- **Single sign-on**, if you've connected this proxy to an SSO Manager (or + another OIDC provider) — the same login your other connected apps use. + +Either way, once logged in, what they're actually *allowed to do* here is +controlled by permissions, described below. + +## Groups + +A **group** here is just a named list of local usernames, used to grant +the same permission to several people at once instead of one at a time. +If you're using SSO instead of local accounts, group membership normally +comes from your identity provider instead — local groups exist mainly for +the local-account case. + +## Permissions: scope + role + +Each **permission** entry grants one subject (a user or a group) one +**role**, at one **scope** — the two are independent choices: + +**Scope** — *where* the role applies: + +- **Domain** — only hosts under one specific domain (e.g. someone can + manage everything under `example.com`, but can't see or touch a + completely different domain you also proxy). +- **Global** — everywhere, across every domain this proxy manages. + +**Role** — *what* they can do within that scope: + +- **Viewer** — read-only. Can see hosts and their settings, but not + change anything. +- **Manager** — full control over hosts (create, edit, delete) within + that scope. +- **Admin** — same host control as Manager, **plus**, but *only when + granted at Global scope*, the ability to manage other people's + permissions, DNS providers, and local user accounts. An Admin role + granted at Domain scope instead of Global behaves exactly like Manager + for that one domain — it does not unlock those extra admin-only pages. + +In practice: give someone **Manager** on just the domain(s) they're +responsible for to delegate day-to-day host management without handing +them the keys to everything. Reserve **Global Admin** for people who +should be able to change anything, anywhere, including who else has +access. + +## Want more detail? + +This page doesn't cover the exact permission-checking implementation or +how SSO group membership maps into this system internally — for that, see +[Architecture](architecture.html). + +[← Back to Home](index.html) diff --git a/docs/proxy/concepts-api-tokens.md b/docs/proxy/concepts-api-tokens.md new file mode 100644 index 0000000..5efe7d4 --- /dev/null +++ b/docs/proxy/concepts-api-tokens.md @@ -0,0 +1,60 @@ +--- +layout: default +title: API Tokens +description: A plain-language guide to personal access tokens in theta42/proxy. +--- + +# 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](api.html). + +## 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 registers or updates hosts automatically (say, spinning up + a new service and wanting the proxy entry created for it without a + manual step). +- A monitoring or backup job that checks this app's health via its API. +- A configuration-management tool that keeps your host list in sync with + something else. + +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](concepts-access.html) +— if you're only a Manager on one domain, a token you create can't touch +any other domain 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](api.html). + +[← Back to Home](index.html) diff --git a/docs/proxy/concepts-dns.md b/docs/proxy/concepts-dns.md new file mode 100644 index 0000000..23e0d62 --- /dev/null +++ b/docs/proxy/concepts-dns.md @@ -0,0 +1,49 @@ +--- +layout: default +title: DNS Providers +description: A plain-language guide to why theta42/proxy needs a DNS provider, and only for wildcard certificates. +--- + +# DNS Providers + +This page explains, in plain language, what a "DNS provider" is for in this +app and when you actually need one. For setup steps, see +[Installation](installation.html). + +## Do you need this at all? + +**Only if you want a [wildcard host](concepts-hosts.html)** (something like +`*.example.com` covering every subdomain with one certificate). A normal, +single-name host doesn't need a DNS provider configured at all — skip this +page entirely if that's all you're setting up. + +## Why a wildcard cert needs this extra step + +To prove you actually own `example.com` before issuing a certificate that +covers *every* possible subdomain of it, Let's Encrypt needs to see a +specific, temporary DNS record appear on that domain — something only the +real owner of the domain could add. A normal single-host certificate +doesn't need this because it can prove ownership a simpler way (by +responding to a web request instead). + +So: to get a wildcard certificate, this app needs to be able to add (and +later remove) that one temporary DNS record on your domain automatically, +which means it needs your domain registrar or DNS host's API credentials — +that's what registering a **DNS provider** here does. + +## What you're actually giving it access to + +A DNS provider entry only needs enough access to add/remove TXT records — +it's not given your registrar account's full login, and it can't do +anything to your domain besides that one narrow task (and, for some +providers, keeping a dynamic A record updated if you use that feature +separately). Check your specific provider's page in the +[Installation guide](installation.html) for exactly what kind of +credential to generate and how narrowly you can scope it. + +## Want more detail? + +For exact setup steps per provider (Cloudflare, DigitalOcean, Porkbun, +DuckDNS, etc.), see [Installation](installation.html). + +[← Back to Home](index.html) diff --git a/docs/proxy/concepts-hosts.md b/docs/proxy/concepts-hosts.md new file mode 100644 index 0000000..504d2e1 --- /dev/null +++ b/docs/proxy/concepts-hosts.md @@ -0,0 +1,81 @@ +--- +layout: default +title: Hosts & HTTPS +description: A plain-language guide to hosts, HTTPS certificates, and wildcards in theta42/proxy. +--- + +# Hosts & HTTPS + +This page explains, in plain language, what a "host" is and how this app +gets you working HTTPS without you having to think about certificates. For +the deeper system-design detail, see [Architecture](architecture.html); for +step-by-step setup, see [Installation](installation.html). + +## What's a "host"? + +A **host** is one entry telling the proxy: "when someone requests *this* +public address, send them to *that* server." For example: requests for +`photos.example.com` get sent to the little box in your closet running your +photo app on port 8080. Each app or service you want to reach from outside +your network — a home automation dashboard, a media server, this proxy's +own management UI — gets its own host entry. + +Two settings on a host are easy to mix up: + +- **Incoming host name** — the public address people type in their + browser (`photos.example.com`). +- **Target IP/port** — where the proxy actually sends the request behind + the scenes (`10.0.0.5:8080`, or a hostname like `photo-server`). + +Everything else on the host form (traffic limits, access rules, +authentication) is optional — a bare host with just those two fields +already works. + +## HTTPS certificates: mostly automatic + +Every public website needs an HTTPS certificate so browsers show the lock +icon instead of a scary warning. This app gets one for you automatically +from [Let's Encrypt](https://letsencrypt.org) the first time a host is +actually requested — you don't manually request, install, or renew +anything for a normal host. This happens behind the scenes using a method +called **HTTP-01**, and it's the default for every new host. + +## Wildcards: one certificate for a whole family of hosts + +Sometimes you want *every* subdomain under one name to work — `app1.`, +`app2.`, `anything.example.com` — without registering each one by hand and +waiting for its own certificate. That's what a **wildcard** host does: a +single host entry named `*.example.com` gets one certificate that covers +the whole family at once. Setting one up needs one extra piece of +information the automatic method above doesn't need — see +[DNS Providers](concepts-dns.html) for why. + +Once a wildcard exists, you have two ways to actually use it: + +- **Register nothing else, and turn on "Match any subdomain"** on the + wildcard host itself — *any* subdomain that doesn't already have its own + entry gets automatically routed to the wildcard's target the first time + it's requested. Convenient, but it means literal typos and random scan + traffic get routed too, not just the subdomains you meant to use. +- **Register each subdomain as its own host, as a "Parent Wildcard" + child** — more setup, but each subdomain can point at a different + target/server while still reusing the one wildcard certificate instead + of getting its own. This is the recommended default and is what + "Match only subdomains defined here" (the host form's default) does. + +You'll see the **"Parent Wildcard"** option light up automatically on the +host form whenever the name you're entering already has a matching +wildcard available to reuse — including the wildcard's own bare base +domain (e.g. `example.com` itself, not just `something.example.com`). + +## Load Balancing + +If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration. + +## Want more detail? + +This page skips the system-internals (Redis, OpenResty, the lookup service) +and the exact install steps. For those, see +[Architecture](architecture.html) and [Installation](installation.html). + +[← Back to Home](index.html) diff --git a/docs/proxy/contributing.md b/docs/proxy/contributing.md new file mode 100644 index 0000000..08aa80f --- /dev/null +++ b/docs/proxy/contributing.md @@ -0,0 +1,344 @@ +--- +layout: default +title: Contributing +description: How to contribute to the proxy — dev setup, tests, and code conventions. +--- + +# Contributing Guide + +[← Back to Home](index.html) + +Thank you for considering contributing to the Proxy project! This guide will help you get started. + +## Development Setup + +### Prerequisites + +- Node.js 18+ (18.x, 20.x, or 22.x recommended) +- Redis server +- Git + +### Local Development + +1. **Clone the repository** + ```bash + git clone https://github.com/theta42/proxy.git + cd proxy/nodejs + ``` + +2. **Install dependencies** + ```bash + npm install + ``` + +3. **Start Redis** (if not already running) + ```bash + redis-server + ``` + +4. **Run in development mode** + ```bash + npm run dev + ``` + + This starts the Node.js API with nodemon for auto-reload on file changes. + +5. **Access the API** + - API: `http://localhost:3000/api` + - Web UI: `http://localhost:3000` + +## Testing + +The project uses Node.js built-in test runner (requires Node 18+). + +### Running Tests + +```bash +# Run all tests +npm test + +# Run only unit tests +npm run test:unit + +# Run only integration tests +npm run test:integration + +# Watch mode for development +npm run test:watch +``` + +### Test Structure + +``` +test/ +├── unit/ # Unit tests for isolated components +│ ├── basicauth.test.js +│ ├── callback_queue.test.js +│ ├── dynamic_record.test.js +│ ├── host_features.test.js +│ ├── host_lookup.test.js +│ ├── hostname_validate.test.js +│ ├── host_sso.test.js +│ ├── oidc.test.js +│ ├── password_policy.test.js +│ ├── roles.test.js +│ ├── safe_redirect.test.js +│ ├── unix_socket.test.js +│ └── wildcard_matchany.test.js +├── integration/ # Integration tests +│ └── dns_provider.test.js +└── helpers/ # Test utilities + └── dns_provider_contract.js +``` + +### Writing Tests + +We test **custom logic**, not third-party libraries: + +**DO test:** +- Host lookup algorithm +- Socket buffering logic +- DNS provider contracts +- Custom utility functions + +**DON'T test:** +- Express.js routing +- Redis ORM +- External DNS APIs (use mocks instead) + +### Adding DNS Provider Tests + +When adding a new DNS provider, you **must** add contract tests: + +```javascript +describe('NewProvider Provider', () => { + const NewProvider = require('../../models/dns_provider/newprovider'); + + test('should meet DNS provider contract', () => { + const mockCredentials = {api_key: 'mock-key'}; + const instance = validateDnsProviderContract(NewProvider, mockCredentials); + assert.ok(instance); + }); + + test('should have valid method signatures', () => { + const instance = new NewProvider({api_key: 'mock'}); + validateMethodSignatures(instance); + }); + + test('should validate key mapping', () => { + const instance = new NewProvider({api_key: 'mock'}); + validateKeyMapping(instance); + }); + + test('should validate type checking', () => { + const instance = new NewProvider({api_key: 'mock'}); + validateTypeChecking(instance); + }); +}); +``` + +See `test/integration/dns_provider.test.js` for examples. + +## Code Style + +### General Guidelines + +- Use strict mode: `'use strict';` +- Use tabs for indentation +- Clear, descriptive variable names +- Comment complex logic +- No trailing whitespace + +### File Organization + +```javascript +'use strict'; + +// 1. Node.js built-ins +const fs = require('fs'); +const path = require('path'); + +// 2. Third-party modules +const express = require('express'); +const redis = require('redis'); + +// 3. Local modules +const {Host} = require('./models'); +const middleware = require('./middleware/auth'); + +// 4. Code... +``` + +### Naming Conventions + +- Classes: `PascalCase` +- Functions: `camelCase` +- Constants: `UPPER_SNAKE_CASE` +- Private methods: `__privateMethod` (double underscore prefix) + +## Project Structure + +Understanding the codebase: + +``` +nodejs/ +├── conf/ # Configuration (base.js, environment overlays, secrets.js) +├── controller/ # App-level wiring (pubsub, startup) +├── migrations/ # One-off Redis data migration scripts +├── models/ # Data models (Host, User, DNS providers) +├── routes/ # API route handlers +├── services/ # Background services (lookup, scheduler) +├── middleware/ # Express middleware +├── utils/ # Utility functions +├── public/ # Static web assets +├── views/ # EJS templates +└── test/ # Test suite +``` + +## Pull Request Process + +### Before Submitting + +1. **Run tests** - Ensure all tests pass + ```bash + npm test + ``` + +2. **Test locally** - Verify your changes work + ```bash + npm run dev + ``` + +3. **Update documentation** - Keep docs in sync with code changes + +4. **Commit messages** - Use clear, descriptive messages + ``` + Add DNS provider for Route53 + + - Implement Route53 DNS API client + - Add contract tests for Route53 + - Update documentation with Route53 setup + ``` + +### Submitting a PR + +1. **Fork the repository** + +2. **Create a feature branch** + ```bash + git checkout -b feature/my-new-feature + ``` + +3. **Make your changes** + +4. **Commit your changes** + ```bash + git add . + git commit -m "Description of changes" + ``` + +5. **Push to your fork** + ```bash + git push origin feature/my-new-feature + ``` + +6. **Open a Pull Request** on GitHub + +### PR Requirements + +- All tests must pass (CI/CD runs automatically) +- Tests run on Node.js 18.x, 20.x, and 22.x +- No merge conflicts with `master` +- Code follows project conventions +- New features include tests +- Documentation updated if needed + +### CI/CD Process + +When you open a PR: +1. GitHub Actions automatically runs tests +2. Tests execute on multiple Node.js versions +3. PR cannot be merged until all checks pass +4. Review from maintainers +5. Merge to master + +## Data Models + +The project uses [model-redis](https://www.npmjs.com/package/model-redis) as the ORM for Redis data storage. All models extend the `Table` class and use a declarative schema via `_keyMap`. + +**Example Model:** +```javascript +const Table = require('../utils/redis_model'); + +class Host extends Table { + static _key = 'host'; // Primary key field + static _keyMap = { + 'host': {isRequired: true, type: 'string', min: 3, max: 500}, + 'ip': {isRequired: true, type: 'string', min: 3, max: 500}, + 'targetPort': {isRequired: true, type: 'number', min: 0, max: 65535}, + 'forcessl': {default: true, type: 'boolean'}, + 'created_on': {default: () => Date.now(), type: 'number'} + }; +} +``` + +**Learn more:** [model-redis documentation](https://www.npmjs.com/package/model-redis) + +## Adding Features + +### Adding a DNS Provider + +1. **Create provider file** in `models/dns_provider/yourprovider.js` + +2. **Extend DnsApi base class** + ```javascript + const {DnsApi} = require('./common'); + + class YourProvider extends DnsApi { + static _keyMap = { + api_key: {isRequired: true, type: 'string', isPrivate: true} + }; + + // Implement required methods + async listDomains() { } + async getRecords(domain, options) { } + async createRecord(domain, options) { } + async deleteRecords(domain, options) { } + } + ``` + +3. **Add to provider list** in `models/dns_provider.js` + +4. **Add contract tests** in `test/integration/dns_provider.test.js` + +5. **Test your provider** + ```bash + npm run test:integration + ``` + +### Adding API Endpoints + +1. **Add route** in appropriate file (`routes/`) +2. **Update API documentation** (`nodejs/api.md` and `docs/api.md` — keep them in sync) +3. **Test the endpoint** manually and add integration tests if needed + +## Getting Help + +- **Questions?** Open a [GitHub Discussion](https://github.com/theta42/proxy/discussions) +- **Bug reports** Use [GitHub Issues](https://github.com/theta42/proxy/issues) +- **Security issues** Email maintainers directly (see package.json) + +## Code of Conduct + +- Be respectful and inclusive +- Focus on constructive feedback +- Help others learn and grow +- Follow the project's technical direction + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +[← Back to Home](index.html) | [View on GitHub](https://github.com/theta42/proxy) diff --git a/docs/proxy/images/host-auth-basic.png b/docs/proxy/images/host-auth-basic.png new file mode 100644 index 0000000..f4d8026 Binary files /dev/null and b/docs/proxy/images/host-auth-basic.png differ diff --git a/docs/proxy/images/host-auth-sso.png b/docs/proxy/images/host-auth-sso.png new file mode 100644 index 0000000..cccb8ec Binary files /dev/null and b/docs/proxy/images/host-auth-sso.png differ diff --git a/docs/proxy/images/hosts.png b/docs/proxy/images/hosts.png new file mode 100644 index 0000000..cb6cf08 Binary files /dev/null and b/docs/proxy/images/hosts.png differ diff --git a/docs/proxy/images/load-balancing.png b/docs/proxy/images/load-balancing.png new file mode 100644 index 0000000..d21f2b9 Binary files /dev/null and b/docs/proxy/images/load-balancing.png differ diff --git a/docs/proxy/index.md b/docs/proxy/index.md new file mode 100644 index 0000000..72458b1 --- /dev/null +++ b/docs/proxy/index.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Home +description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host. +--- + +# Proxy + +A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a +management API and web GUI. It puts any of your apps behind single sign-on +(OIDC) and can also look users up directly in LDAP — so the same people who +log in to your SSO are the people allowed to reach your proxied apps. + +Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname, +and per-host access control tied to your identity provider — managed from a +web UI or a REST API, with no downtime on config changes. + +Part of the theta42 self-hosted identity stack, alongside +[SSO Manager](https://theta42.github.io/sso-manager-node/) and +[theta-env](https://theta42.github.io/theta-env/) (the two composed with one +command). + +## Screenshots + +Host list +Per-host SSO auth + +Basic auth and SSO are mutually exclusive per host, with per-user password +management once basic auth is enabled: + +Per-host basic auth + +*(click any screenshot to view full size)* + +## Why this over the alternatives + +Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with +auto-HTTPS. This one is built around identity: it is both an **OIDC client** +of an SSO provider (for browser login) **and** a direct **LDAP client** (for +user lookups and per-host access control), so access decisions come from your +real user directory, not a static allow-list or a separate auth proxy bolted +on top. The trade-off is that it expects an OIDC/LDAP identity source to point +at — it is not an auth server on its own. Pair it with +[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP + +OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider + +LDAP directory you already run. + +## Features + +- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges +- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free) +- Dynamic host routing with wildcard domain matching (`*`, `**`) +- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing +- **OIDC login** and **direct LDAP lookups**, independently of each other +- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so + it's never ambiguous which one gated a request) +- **Role-based access control** — global admins, local groups, and + per-domain permissions (viewer/manager) +- Self-service API tokens for scripting/CI without a browser session +- Web UI and a full REST API + +## Get it + +```bash +git clone https://github.com/theta42/proxy.git +cd proxy && docker compose up -d --build +``` + +For the full set of install options (Docker, bare-metal, or as part of the combined +SSO + proxy stack), configuration reference, and API docs, see the +**[GitHub repository](https://github.com/theta42/proxy)**. + +## Related projects + +- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC + provider + LDAP directory this proxy is designed to sit in front of. +- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and + SSO Manager together with one command. diff --git a/docs/quickstart.md b/docs/quickstart.md index 7491ac0..633bbfc 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -183,7 +183,6 @@ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ under **API Tokens** in each UI, mint a personal access token and use it as `Authorization: Bearer sso_…` (SSO) or `prx_…` (proxy). A token authenticates as its creator with their permissions. See each submodule's DEPLOYMENT. -- See [Architecture](architecture.html) for how it all fits together, and - [Standalone](standalone.html) to run either project on its own. +- See [Architecture](architecture.html) for how it all fits together. [← Back to Home](index.html) \ No newline at end of file diff --git a/docs/sso/_layouts/default.html b/docs/sso/_layouts/default.html new file mode 100644 index 0000000..39af120 --- /dev/null +++ b/docs/sso/_layouts/default.html @@ -0,0 +1,82 @@ + + + + + + + + {% seo title=false %} + {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} + + + + + + + + + +
+
+
+
+
+
+ {{ content }} +
+
+
+
+
+
+ + + + + + diff --git a/docs/sso/agents.md b/docs/sso/agents.md new file mode 100644 index 0000000..44e6768 --- /dev/null +++ b/docs/sso/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/sso/assets/css/style.css b/docs/sso/assets/css/style.css new file mode 100644 index 0000000..e24a5de --- /dev/null +++ b/docs/sso/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/sso/assets/img/theta42.svg b/docs/sso/assets/img/theta42.svg new file mode 100644 index 0000000..e598305 --- /dev/null +++ b/docs/sso/assets/img/theta42.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 42 + + diff --git a/docs/sso/concepts-accounts.md b/docs/sso/concepts-accounts.md new file mode 100644 index 0000000..e782ab5 --- /dev/null +++ b/docs/sso/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/sso/concepts-api-tokens.md b/docs/sso/concepts-api-tokens.md new file mode 100644 index 0000000..36eeb9d --- /dev/null +++ b/docs/sso/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/sso/concepts-oauth-apps.md b/docs/sso/concepts-oauth-apps.md new file mode 100644 index 0000000..5479056 --- /dev/null +++ b/docs/sso/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/sso/configuration.md b/docs/sso/configuration.md new file mode 100644 index 0000000..1e6c36c --- /dev/null +++ b/docs/sso/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/sso/directory.md b/docs/sso/directory.md new file mode 100644 index 0000000..1287c6c --- /dev/null +++ b/docs/sso/directory.md @@ -0,0 +1,139 @@ +--- +layout: default +title: Directory Management +description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API. +--- + +# Directory Management + +The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services. + +## Architecture + +The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`). + +There are three primary **Kinds** of resources you can define: +- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents. +- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host. +- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service. +- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service. + +By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure. + +## Automatic LDAP Group Creation + +When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource: + +1. `_access` (Member level access) +2. `_admin` (Owner level access) + +For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard. + +## Resource Metadata + +Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields: + +### Common Metadata +- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`). +- **IP Address**: The internal IP address of the resource. +- **MAC Address**: The hardware address of the primary interface. +- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`). +- **Production Environment**: A boolean toggle indicating if the resource is in production. + +### Host Metadata +- **VMID**: The hypervisor VM or Container ID (e.g. `101`). +- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`). +- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`). + +### Service Metadata +- **Internal Port**: The local port the service binds to (e.g. `8080`). +- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank). +- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication. +- **External Reachable**: Indicates if the service is accessible outside the VPN/local network. +- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`). +- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`). +- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`). + +### Who sees which metadata + +Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers: + +- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`. +- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`). +- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included. + +Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too. + +## Catalog & access requests + +The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows: + +- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh _-_@`; otherwise they fall back to a direct `ssh @`. +- **Discover More** — everything else in the directory, with a **Request access** button. +- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource. + +A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring. + +Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service. + +## Navigating the UI + +The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it. + +Directory & inventory list view + +## Slug conventions + +Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention: + +- **Sites**: `site_` — e.g. `site_local`, `site_us-east` +- **Hosts**: `host_` — e.g. `host_pve1`, `host_web01` +- **Services/apps**: a plain slug or `app_` — e.g. `sso-manager`, `app_emby` + +The auto-created LDAP groups derive from the slug (`_access` / `_admin`), so keep slugs stable once access groups are in use. + +## Automatic registration + +You don't have to build the graph by hand — the theta42 tooling registers itself: + +### The stack itself (theta-env) + +[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys: + +- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site +- the **host** the stack runs on (`host_`), with IP, MAC address, OS, and kernel collected from the machine +- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo +- the proxy's auto-registered **OAuth client**, linked under its service + +The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values. + +### Linux hosts (ldap-client) + +The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location. + +## Consumers of the directory + +The inventory graph isn't just documentation — other components read it to make decisions: + +- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=`), then bridges them in. The `host_` slugs and `host__access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group. + +Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9. + +## API + +All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`): + +- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id` +- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations) +- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links +- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column) +- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group +- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me` + +Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin): + +- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}` +- `GET /api/access-requests/mine` — the caller's own history +- `GET /api/access-requests` — pending requests the caller may decide +- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny` +- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request diff --git a/docs/sso/images/dashboard.png b/docs/sso/images/dashboard.png new file mode 100644 index 0000000..18b4a81 Binary files /dev/null and b/docs/sso/images/dashboard.png differ diff --git a/docs/sso/images/directory.png b/docs/sso/images/directory.png new file mode 100644 index 0000000..9b1a4b3 Binary files /dev/null and b/docs/sso/images/directory.png differ diff --git a/docs/sso/images/groups.png b/docs/sso/images/groups.png new file mode 100644 index 0000000..ffcb461 Binary files /dev/null and b/docs/sso/images/groups.png differ diff --git a/docs/sso/images/oauth-clients.png b/docs/sso/images/oauth-clients.png new file mode 100644 index 0000000..e8790d5 Binary files /dev/null and b/docs/sso/images/oauth-clients.png differ diff --git a/docs/sso/images/sites.png b/docs/sso/images/sites.png new file mode 100644 index 0000000..797e8e8 Binary files /dev/null and b/docs/sso/images/sites.png differ diff --git a/docs/sso/images/users.png b/docs/sso/images/users.png new file mode 100644 index 0000000..60be54a Binary files /dev/null and b/docs/sso/images/users.png differ diff --git a/docs/sso/index.md b/docs/sso/index.md new file mode 100644 index 0000000..6296519 --- /dev/null +++ b/docs/sso/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 +``` + +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/sso/ldap.md b/docs/sso/ldap.md new file mode 100644 index 0000000..6ef41a0 --- /dev/null +++ b/docs/sso/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) +— 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/sso/oauth.md b/docs/sso/oauth.md new file mode 100644 index 0000000..230a784 --- /dev/null +++ b/docs/sso/oauth.md @@ -0,0 +1,111 @@ +--- +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. + +## 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/sso/replication.md b/docs/sso/replication.md new file mode 100644 index 0000000..06955ad --- /dev/null +++ b/docs/sso/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/sso/vault.md b/docs/sso/vault.md new file mode 100644 index 0000000..a301682 --- /dev/null +++ b/docs/sso/vault.md @@ -0,0 +1,45 @@ +--- +layout: default +title: Secrets Vault +nav_order: 6 +--- + +# Secrets Vault + +SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys. + +The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container. + +## Architecture + +The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability. + +When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment. + +## Accessing the Vault + +The SSO Manager Vault can be accessed in two ways: + +1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it. +2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token. + +### API Example + +To read secrets from the default key-value store, issue a `GET` request to: +`/api/vault/v1/secret/data/sso-manager/conf` + +Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints. + +## Namespaces and Paths + +Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations). + +## Plugin Integration + +Plugin instances store their per-instance secrets in OpenBao at +`secret/plugins//conf` (configured, loaded/unloaded, and run from +the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs +in-process, so the SSO Manager reads/writes those secrets server-side through +the `sso-broker` token; the admin UI only ever sees masked values, and external +apps can retrieve API tokens via the `/api/vault` proxy to keep permissions +consistently enforced instead of hardcoding them. diff --git a/docs/standalone.md b/docs/standalone.md deleted file mode 100644 index 71a0d3b..0000000 --- a/docs/standalone.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -layout: default -title: Standalone -description: Running a component individually, without theta-suite's orchestration — an advanced path; the integrated stack is the supported one. ---- - -# Running a component individually - -[← Back to Home](index.html) - -> **The integrated stack is the supported path.** `./setup.sh` wiring all four -> components together around a shared OpenBao secrets store is what's tested and -> released. The steps below are for the advanced case where you want to run one -> component on its own — a separate host, a different network, or without the -> orchestrator. Running standalone means managing secrets from the -> `config/*-secrets.js` file only (no shared OpenBao) and doing the OIDC/LDAP -> wiring by hand. - -The submodules in this repo are normal clones; you can also clone them directly -from GitHub. Each component builds and runs on its own. - ---- - -## SSO Manager alone - -The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis: - -```bash -git clone https://github.com/theta42/sso-manager-node.git -cd sso-manager-node -mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it -docker compose up -d --build -``` - -The entrypoint points the `CONF_SECRETS` env var at `config/sso-secrets.js` so -`@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and -the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass -**no `app_*` env** — env beats the secrets file, so `app_*` would silently -override your file. - -- Web UI: `http://localhost:3001` -- Health: `http://localhost:3001/health` -- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` -- LDAPS: `ldaps://:636` - -Requires `@simpleworkjs/conf` >= 1.2.0. Full reference: -[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html). - -### Bare metal - -```bash -sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' -n 'Your Org' -o 3001 -sudo systemctl enable --now sso-manager -``` - -Idempotent — re-run to update. See the SSO Manager -[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html). - ---- - -## Proxy alone - -The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis: - -```bash -git clone https://github.com/theta42/proxy.git -cd proxy -mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it -docker compose up -d --build -``` - -The entrypoint points the `CONF_SECRETS` env var at `config/proxy-secrets.js` -so `@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints + -`clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and -`auth` (admin groups/users). Pass **no `app_*` env** — env beats the secrets -file, so `app_*` would silently override your file. - -- Proxy (public, auto-SSL): `https:///` -- Mgmt UI / API: `http://127.0.0.1:3000/` -- Health: `http://127.0.0.1:3000/health` - -Requires `@simpleworkjs/conf` >= 1.1.0. Full reference: -[proxy deployment docs](https://theta42.github.io/proxy/docker.html). - -### The `auth.adminUsers` anti-lockout account - -Both `setup.sh` and `config.example/proxy-secrets.js.example` write -`auth.adminUsers: ['proxyadmin2']` into `proxy-secrets.js`. This is a -**local, config-driven admin bypass** — the proxy grants full admin rights to -any logged-in OIDC user whose username (the `preferred_username` claim from -the SSO) matches an entry in `auth.adminUsers`, regardless of their LDAP group -membership (see `proxy/nodejs/utils/roles.js`, `resolveEffective()`). It exists -so an operator can't lock themselves out of the proxy mgmt UI if the SSO's -`app_sso_admin` group is ever misconfigured, deleted, or otherwise broken. - -It is **not** derived from any `setup.env` value, and it does **not** create a -user by itself — the name is only a username match. To actually use the -bypass, create a user with uid `proxyadmin2` in the SSO (it does not need to -be in `app_sso_admin` or any other group) and log in through the proxy as that -user. - -To change or disable it, edit `auth.adminUsers` directly in -`./config/proxy-secrets.js` after the first `./setup.sh` run (re-running -`setup.sh` will not overwrite an existing `proxy-secrets.js`): - -- **Rename** it to a less guessable username: `adminUsers: ['your-break-glass-uid']`. -- **Add more** anti-lockout accounts: `adminUsers: ['proxyadmin2', 'another-admin']`. -- **Disable** it entirely: `adminUsers: []` (global admin then comes only from - `auth.adminGroups` membership — make sure at least one real admin group is - reachable before doing this). - -### Bare metal - -```bash -wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash -``` - -See the proxy -[Docker guide](https://theta42.github.io/proxy/docker.html) / -[installation guide](https://theta42.github.io/proxy/installation.html). - ---- - -## Wiring components together by hand - -If you have a specific reason to run the components on separate hosts instead -of through `./setup.sh` (and accept that you lose the shared OpenBao secrets -store), the four wiring steps are documented in both projects' deployment -guides: - -1. One Docker network (or reachable hostnames) so the proxy can reach the SSO - internally for token/userinfo + LDAPS. -2. Set the SSO's `oauth.issuer` (in its `secrets.js`) to the browser-facing HTTPS - URL the proxy serves the SSO at. -3. Register the proxy as an OIDC client in the SSO, with `redirectUri` matching - the proxy's callback; put the resulting `clientId`/`clientSecret` in the - proxy's `secrets.js`. -4. Point the proxy's `ldap.url` at the SSO's LDAPS + create a dedicated - `cn=ldapclient` service account; set the same password as `bindPassword`. - -`./setup.sh` exists to do all of this for you — and to add the OpenBao secrets -store, jump host, and ldap-client on top. Unless you need the components on -separate hosts, prefer the integrated stack. - -[← Back to Home](index.html) \ No newline at end of file