Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5166859f29 | |||
| f05fb27e5e | |||
| 33dfb682f4 | |||
| da60310834 | |||
| 20e9c1dfbe | |||
| 98b2f9f389 | |||
| 90a1d19254 | |||
| 55d6f0b936 | |||
| ba2e905155 | |||
| 455450db1f | |||
| 5ed83a2f59 | |||
| ca812bed8e | |||
| 0e59b8dcb4 | |||
| 2d862f93c9 | |||
| e0bdc0df2e | |||
| 577c264a6a | |||
| 4f09354e32 | |||
| 744c85f4bf | |||
| d8d811b0ab | |||
| 42ec5aa208 | |||
| 734c62ac83 | |||
| 1d85aa81f3 | |||
| 47f7f976ab | |||
| ace7b441c2 | |||
| 35c1a476c9 | |||
| aeed5b8723 | |||
| 6640f8059a | |||
| d6611c7d1b | |||
| 9aaa35fa4e | |||
| 182787f268 | |||
| d7698a60e7 | |||
| 484bf0e91d | |||
| f42b69c084 | |||
| 0367c33542 | |||
| ff9c87ff20 | |||
| 423e064147 | |||
| 52c9c30c52 | |||
| ea75e94b3e | |||
| 301770321e | |||
| c6c6f09d48 | |||
| cccde0792e | |||
| a2afc127c4 | |||
| ad2f7b7e11 | |||
| ec73eae07d | |||
| 13aeac059f | |||
| 349d3d4cd0 | |||
| d8725990b3 | |||
| de2d65fe8f | |||
| e5446b2cfe | |||
| 0c87c79f06 | |||
| c7bf1d0edd | |||
| a917915037 | |||
| 141e5e14f0 | |||
| f09d38d00b | |||
| 8632798735 | |||
| 17f488e2e2 | |||
| 3ec641c426 | |||
| 69a7843dca | |||
| 4db87de240 | |||
| 9c521b0d08 | |||
| e091ca406c | |||
| 3c40c66636 | |||
| 2a0d194cae | |||
| 656c2ed8c8 | |||
| 282071abca | |||
| 1d742c51eb | |||
| 85a822eb36 | |||
| 489fe7b127 | |||
| ad6f17515b | |||
| eca92f3f37 | |||
| acea5217ac | |||
| 47ceb63049 | |||
| 86d1601069 | |||
| 8bfde63684 | |||
| 603156cbfe | |||
| 277b68af61 | |||
| 7d55048905 | |||
| b799d59672 | |||
| f46ef31ca9 | |||
| 8451d3f12d | |||
| 67e7e4eb34 | |||
| 403e1a3cd4 | |||
| 28fad8a49e | |||
| 70fc19e37f | |||
| 5bb2c19fec | |||
| ff9b62d331 | |||
| fabb250887 | |||
| 221e890897 | |||
| 5925940936 | |||
| ea5d2350a4 | |||
| 050ff87a0b | |||
| bc87d4e381 | |||
| 7efc271938 | |||
| 4bf875e841 | |||
| 6479d35fb8 | |||
| 3ca4802075 | |||
| 86026e90e7 | |||
| 3354407f04 | |||
| 84d7c96c17 | |||
| 72046a8b29 | |||
| 73e1cc807a | |||
| a77aa8d2df | |||
| 0e78a9e282 | |||
| 59c5c66007 | |||
| ec426680c3 | |||
| 6fae96d977 | |||
| 292b67c334 | |||
| 72606bfc13 | |||
| c28e53e505 | |||
| 7ae2472c62 | |||
| 0c4abd82be | |||
| c27e8c7867 | |||
| 9750413178 | |||
| c1a9d8f059 | |||
| 0edce57f80 | |||
| 49b868fedb | |||
| f9af81983b | |||
| 5ef2493e17 | |||
| 8535372123 | |||
| ade9a41aed | |||
| ce664f5cb9 | |||
| 647f5b846c | |||
| fe08c2f8c7 | |||
| 43c58e7ed5 | |||
| 13d8a979c7 | |||
| 16cbe46793 | |||
| 40a1e7f64e | |||
| 45715b61ea | |||
| 54ebb83bb1 | |||
| a67d972217 | |||
| 875ea874b4 | |||
| 119f21b821 | |||
| 80275c42e4 | |||
| a30deae866 | |||
| dc9a7ff9c4 | |||
| 7937d6cf92 | |||
| 21333de814 | |||
| b3bdebe1c9 | |||
| e6b318e28e | |||
| 4d0b7f555e | |||
| 67b511f8d7 | |||
| b8a8be9697 | |||
| cd9c81cd92 | |||
| c8c04440db | |||
| d53bdefc2a | |||
| 542e5fd33f | |||
| 848f35fc5e | |||
| 1d14fcee19 | |||
| 30609de3e8 | |||
| 925ac027a6 | |||
| 3b769cf24a | |||
| 2785b861b3 | |||
| c216ddd4e8 | |||
| 9c3cbb0ec2 | |||
| f44c075ede | |||
| ac9f672bae | |||
| 43b8307e54 | |||
| 9541c47470 | |||
| a40326778e | |||
| 68a0ce4d12 | |||
| 3cb5540b2d | |||
| 4d745a9c1d | |||
| 36b39dfcc7 | |||
| 06a4081a50 | |||
| 2f793206ed | |||
| f7e9c20f72 | |||
| 5ee486e808 | |||
| 475ae2c893 | |||
| 2eb5bf5daa | |||
| 35c8a51182 | |||
| f3b951b780 | |||
| 8f5ce71bda | |||
| 6de31aa5e0 | |||
| 6c02e6c63e | |||
| e2e8143880 | |||
| aa01a5cc07 | |||
| 1185bb90b8 | |||
| 403e66556c | |||
| 3287777b9b | |||
| 5ef3e3fa8c | |||
| a1ea2d458e | |||
| f7df04c2f0 | |||
| 3277972037 | |||
| 0c7593cc59 | |||
| ec625f7cb0 | |||
| 2a6c7775c9 | |||
| 010ff037ce | |||
| f8f1961b30 | |||
| 49dee5c477 | |||
| 1a832d068e | |||
| 60ae421cb2 | |||
| 90e95bfece | |||
| 144e97d0e1 | |||
| 9dc2de7818 | |||
| a959b331ae | |||
| b64083f008 | |||
| a2fa7a7fc7 | |||
| 6d6aea6011 | |||
| 43e3c79880 | |||
| 8b549c3315 | |||
| f25a684eb2 | |||
| 87d06441c7 | |||
| cf1591cdaa | |||
| d4154cfec6 | |||
| 6e411160a6 | |||
| e25d112787 | |||
| 3b5f3423c8 | |||
| 084c8fdfb3 | |||
| f2e924bf58 | |||
| 7c0fbd959f | |||
| e1cb855529 | |||
| 51e9e5e42f | |||
| e27439e491 | |||
| cf8c5c9a04 | |||
| c7c0aa8cf5 | |||
| 538b939f9e | |||
| d61e661099 | |||
| 81046a186f | |||
| ab9d9301f0 | |||
| ffc8af562a | |||
| a308fc8bbc | |||
| b6c8fe5a89 | |||
| b1cfaa1046 | |||
| 2f0e291b29 | |||
| db2db5095b | |||
| 79f1f62318 | |||
| d8717fd613 | |||
| f8a213a3bf | |||
| f90d319eeb | |||
| 27ab105325 | |||
| 5aaec1b18a | |||
| 8c7648781e | |||
| 46815e681c | |||
| a888624f38 | |||
| 191ef0a55f | |||
| 128083aee6 | |||
| f59f987115 | |||
| 71e8c09e8f | |||
| acc61a4c3d | |||
| 67f62276e7 | |||
| 6ef12408df | |||
| c3d232f7cc | |||
| 285cc4fbef | |||
| 5299556057 | |||
| cc03b3758c | |||
| 2d0496cfda | |||
| 48df638ddd | |||
| d6d2c7144a | |||
| 67374dc914 | |||
| a3b41c6775 | |||
| b25fb56a0d | |||
| a15002b588 | |||
| fe8133b21c | |||
| c83248e40b | |||
| e5a5eef428 | |||
| d098ba7082 | |||
| 1f8f4c70be | |||
| 9671339076 | |||
| aa74ca1b8c | |||
| cf919de1a6 | |||
| 3e85e37b63 | |||
| 75278f3e16 | |||
| faf67d8ffc | |||
| 892069eaea | |||
| 4f61eeb1a7 | |||
| f51b7e2dbe | |||
| 1c96c75118 | |||
| 5c8e5be0c5 | |||
| 4b613c7ca1 | |||
| a118f7ad4e | |||
| 87c8c0fc02 | |||
| 43cdf1dbbb | |||
| 9a737dd178 | |||
| fea1237c46 | |||
| 0b55535aa9 | |||
| 2baf8acd64 | |||
| 3943ed02c5 | |||
| 7f43eee36e | |||
| 19ea7e012a | |||
| 94b357e915 | |||
| f5d8cdd09d | |||
| 96b3aec5eb | |||
| 5aff5349a8 | |||
| 3b0f8f1f9a | |||
| 28016376ad |
@@ -1,4 +1,4 @@
|
||||
# theta-env — unified SSO Manager + Proxy deployment.
|
||||
# theta-suite — unified SSO Manager + Proxy deployment.
|
||||
#
|
||||
# Copy this file to `.env` and fill in the values, then run `./setup.sh`.
|
||||
# All values are read by setup.sh / docker-compose / the bootstrap.
|
||||
@@ -76,4 +76,10 @@ MGMT_BIND=0.0.0.0
|
||||
# Defaults to LDAP_DOMAIN. Set to the hostname the proxy connects via
|
||||
# (sso-manager inside the docker net uses the service name, which is in the
|
||||
# cert's SAN, so the default is usually fine).
|
||||
LDAP_CERT_CN=
|
||||
LDAP_CERT_CN=
|
||||
|
||||
# ── Optional: LDAPS hostname shown on the SSO /integrations page ────────────────
|
||||
# Leave blank to derive from the public SSO host (SSO_HOST). Set an internal-only
|
||||
# name like 'ldap.internal.example.com' or 'sso-manager' so direct-LDAP clients
|
||||
# don't need a public 636 port forward. See docs/ldap.md for network layouts.
|
||||
LDAPS_HOST=
|
||||
@@ -0,0 +1,62 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "master" ]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches: [ "main", "master" ]
|
||||
|
||||
jobs:
|
||||
build-theta-agent:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Build Agent
|
||||
working-directory: ./theta-agent
|
||||
run: go build -v ./...
|
||||
|
||||
docker-push:
|
||||
needs: [build-theta-agent]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push SSO Manager
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./sso-manager-node
|
||||
file: ./sso-manager-node/Dockerfile.openldap
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/sso-manager:latest
|
||||
ghcr.io/${{ github.repository_owner }}/sso-manager:${{ github.ref_name }}
|
||||
|
||||
- name: Build and Push Proxy
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./proxy
|
||||
file: ./proxy/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/theta-proxy:latest
|
||||
ghcr.io/${{ github.repository_owner }}/theta-proxy:${{ github.ref_name }}
|
||||
@@ -1,8 +1,10 @@
|
||||
name: Lint
|
||||
|
||||
# theta-env has no app code of its own to unit-test (it orchestrates the
|
||||
# theta-suite has no app code of its own to unit-test (it orchestrates the
|
||||
# proxy/sso-manager-node submodules) -- this checks the one thing that can
|
||||
# actually break silently: setup.sh and bootstrap.js.
|
||||
# actually break silently: setup.sh and bootstrap.js, plus a static
|
||||
# consistency check on the config bootstrap.js generates for jump-host
|
||||
# (test/check_jump_ldap_tls.js).
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -26,6 +28,8 @@ jobs:
|
||||
run: shellcheck -S warning setup.sh
|
||||
|
||||
bootstrap-syntax:
|
||||
# Keep this job name stable: branch protection on master requires a status
|
||||
# check named exactly "Syntax check bootstrap.js".
|
||||
name: Syntax check bootstrap.js
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -38,4 +42,9 @@ jobs:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Syntax check
|
||||
run: node --check bootstrap/bootstrap.js
|
||||
run: |
|
||||
node --check bootstrap/bootstrap.js
|
||||
node --check bootstrap/site-join.js
|
||||
|
||||
- name: Jump-host LDAP config consistency
|
||||
run: node test/check_jump_ldap_tls.js
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
config/
|
||||
backups/
|
||||
|
||||
# Legacy .env / proxy.env (no longer used — config is in ./config/). Still
|
||||
# ignored in case a migrated deployment hasn't deleted them yet.
|
||||
# .env: NOT app config (that's ./config/, generated by setup.sh) — this is
|
||||
# docker compose's own auto-loaded env file, which setup.sh uses only to
|
||||
# persist *_GIT_COMMIT build args so an ad-hoc rebuild of a single service
|
||||
# still bakes the right commit hash. Generated; never commit.
|
||||
# proxy.env: legacy, no longer used — still ignored in case an old
|
||||
# deployment hasn't deleted it yet.
|
||||
.env
|
||||
proxy.env
|
||||
|
||||
@@ -15,6 +19,11 @@ proxy.env
|
||||
# per-deployment and is not committed.
|
||||
setup.env
|
||||
|
||||
# spoke.env — same rule as setup.env, but for the join-a-cluster vars split
|
||||
# out for clarity (spoke.env.example IS committed). Holds a real site join
|
||||
# key once filled in.
|
||||
spoke.env
|
||||
|
||||
# Backup artifacts (hold secrets — the whole user directory + Redis dumps)
|
||||
*.rdb
|
||||
*.ldif
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
[submodule "sso-manager-node"]
|
||||
path = sso-manager-node
|
||||
url = https://github.com/theta42/sso-manager-node.git
|
||||
url = https://github.com/theta42/theta-directory.git
|
||||
[submodule "proxy"]
|
||||
path = proxy
|
||||
url = https://github.com/theta42/proxy.git
|
||||
[submodule "jump-host"]
|
||||
path = jump-host
|
||||
url = https://github.com/theta42/jump-host.git
|
||||
branch = master
|
||||
[submodule "ldap-client"]
|
||||
path = ldap-client
|
||||
url = https://github.com/theta42/ldap-client.git
|
||||
[submodule "theta-agent"]
|
||||
path = theta-agent
|
||||
url = https://github.com/theta42/theta-agent.git
|
||||
|
||||
@@ -1,64 +1,70 @@
|
||||
# theta-env
|
||||
# Theta Suite 2.0
|
||||
|
||||
The whole theta42 identity + access stack in one repo, brought up with a single
|
||||
command — for home labs and small businesses.
|
||||
Theta Suite 2.0 is your production-grade, single-command solution for replacing fragmented identity, gateway, and host management setups with a unified zero-trust infrastructure stack. It seamlessly integrates OIDC identity, LDAP directories, automated host enrollment, WireGuard mesh routing, and centralized secret management in one command.
|
||||
|
||||
It wires together two projects that already work on their own:
|
||||
Theta Suite composes core applications around a shared [OpenBao](https://openbao.org/) secrets store, brought up with a single `./setup.sh`:
|
||||
|
||||
- **[SSO Manager](https://github.com/theta42/sso-manager-node)** — an OIDC
|
||||
provider with a built-in LDAP directory (OpenLDAP) and a web UI for managing
|
||||
users, groups, and OAuth clients.
|
||||
- **[theta42/proxy](https://github.com/theta42/proxy)** — an OIDC-protected
|
||||
reverse proxy (OpenResty) that puts any of your apps behind SSO login and can
|
||||
look users up directly in LDAP.
|
||||
- **[Theta Directory](https://github.com/theta42/sso-manager-node)** — an OIDC provider with a built-in OpenLDAP directory, resource catalog, IAM group access controls, and administrative web console.
|
||||
- **[Theta Gateway](https://github.com/theta42/jump-host)** — directory-driven SSH access gateway and integrated WireGuard mesh network router with site-aware target filtering and NETMAP shadow subnets.
|
||||
- **[Theta Agent](https://github.com/theta42/theta-agent)** — lightweight multi-platform host telemetry and desktop control agent for Linux (amd64, arm64, armv7), macOS (Intel, Apple Silicon), and Windows.
|
||||
- **[Theta Proxy](https://github.com/theta42/proxy)** — an OIDC-protected reverse proxy (OpenResty) that puts web applications behind directory authentication with direct LDAP user lookups.
|
||||
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls Linux hosts into the directory for PAM/SSSD login, sudo, and SSH keys.
|
||||
|
||||
Each project still runs **standalone** (`docker compose up` in its own folder);
|
||||
this repo just composes them and automates the first-run glue so they find each
|
||||
other.
|
||||
All applications load their secrets from OpenBao at boot; `./setup.sh` automates the first-run glue so components discover each other and the secrets engine automatically.
|
||||
|
||||
**Documentation:** [https://theta42.github.io/theta-env/](https://theta42.github.io/theta-env/)
|
||||
**Site:** [https://theta42.github.io/theta-suite/](https://theta42.github.io/theta-suite/)
|
||||
|
||||
## Screenshots
|
||||
|
||||
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
|
||||
The Theta Directory and Theta Proxy, stood up by one `./setup.sh` run:
|
||||
|
||||
| SSO Manager Dashboard | Proxy Hosts |
|
||||
| Theta Directory Dashboard | Proxy Hosts |
|
||||
| --- | --- |
|
||||
| [](docs/images/sso-dashboard.png) | [](docs/images/proxy-hosts.png) |
|
||||
| [](docs/images/sso-dashboard.png) | [](docs/images/proxy-hosts.png) |
|
||||
|
||||
**Why use this instead of running the two separately?** The two only become
|
||||
useful once the proxy is registered as an OIDC client of the SSO and pointed at
|
||||
the SSO's LDAP directory — and the SSO's domain has to match across half a dozen
|
||||
config fields or logins silently fail with `Invalid Credentials`. Doing that by
|
||||
hand is fiddly and easy to get wrong. `setup.sh` asks for your domain once (in
|
||||
`setup.env`), generates both config files with it filled in everywhere, registers
|
||||
the proxy as an OIDC client, and snapshots state before every rebuild — so you
|
||||
get a working SSO + proxy stack in one command and a safe way to upgrade it.
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ your browser / apps │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ https
|
||||
┌─────────▼─────────┐
|
||||
│ proxy │ OpenResty :80/:443/:4443
|
||||
│ (OIDC + LDAP) │ mgmt app :3000 (localhost)
|
||||
└─────────┬─────────┘ bundled redis
|
||||
┌─────────────┼──────────────────────┐
|
||||
│ ldaps:636 │ http:3001 (internal)│ OIDC token/userinfo
|
||||
▼ ▼ │
|
||||
┌──────────────────────────┐ │
|
||||
│ sso-manager │◄────────────────┘
|
||||
│ OIDC provider + OpenLDAP │ bundled redis
|
||||
│ web UI :3001 (localhost) │
|
||||
│ ldaps :636 (LAN clients) │
|
||||
└───────────────────────────┘
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ browser / OIDC apps │ SSH clients │ Linux hosts │
|
||||
│ │ │ (PAM/SSSD, sudo, keys)│
|
||||
└───────┬─────────────┴───────┬─────┴────────────┬──────────┘
|
||||
https (:443) ssh (:2222) ldaps (:636)
|
||||
│ │ │
|
||||
┌────────▼─────────┐ ┌────────▼──────────┐ │
|
||||
│ theta-proxy │ │ theta-gateway │ │
|
||||
│ OpenResty │ │ SSH Gateway │ │
|
||||
│ :80/:443/:4443 │ │ WireGuard Mesh │ │
|
||||
│ mgmt app :3000 │ └────────┬──────────┘ │
|
||||
└────────┬─────────┘ │ OIDC + LDAP │
|
||||
│ http:3001 (internal)│ via theta-directory
|
||||
▼ ▼ ▼
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
│ theta-directory (Express + OpenLDAP + Redis) │
|
||||
│ OIDC provider + LDAP directory + Resource Catalog │
|
||||
│ web UI :3001 (internal) ldaps :636 (published) │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
▲ loads secrets at boot (scoped token each)
|
||||
┌───────────┴───────────────────┐
|
||||
│ openbao (KV-v2 at secret/) │ ← central secrets store
|
||||
│ :8200 (internal) │ per-user + per-app KV
|
||||
│ :8080 (operator UI/API) │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
The proxy fronts the SSO Manager UI under TLS and protects it with OIDC login.
|
||||
It is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
|
||||
client (for user lookups). Legacy apps can still bind to LDAPS on the SSO
|
||||
directly.
|
||||
The proxy fronts the SSO Manager UI (and the jump-host web UI) under TLS and
|
||||
protects them with OIDC login. It is **both** an OIDC client of the SSO (for
|
||||
login) **and** a direct LDAP client (for user lookups). Legacy apps can still
|
||||
bind to LDAPS on the SSO directly. See
|
||||
[docs/architecture.md](docs/architecture.md) for the full diagram (ports,
|
||||
secrets flow, jump-host, ldap-client) and [docs/secrets.md](docs/secrets.md)
|
||||
for the OpenBao model.
|
||||
|
||||
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a browser session.
|
||||
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine (`theta-agent`, specialized subtype driver, Proxmox hypervisor fallback, unmanaged) for systemd, docker, proxmox, wireguard, database, and k8s resources.
|
||||
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), guaranteeing strict secret scoping across services and containers.
|
||||
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
|
||||
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,22 +123,32 @@ see browser warnings.)
|
||||
|
||||
Optional extra ports (only if you need them):
|
||||
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
|
||||
- **636** (LDAPS) — only if a legacy app on another machine binds to LDAP
|
||||
directly over the network. The proxy itself reaches LDAP over the internal
|
||||
Docker network, so you do **not** need to expose 636 for the stack to work.
|
||||
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
|
||||
enrollment (`setup.sh` → ldap-client) configures its sssd against
|
||||
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
|
||||
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
|
||||
lock to the host). LAN clients (Linux hosts via PAM/SSSD, LDAP-native apps) can
|
||||
bind over either; the proxy itself reaches LDAP over the internal Docker
|
||||
network and doesn't need them.
|
||||
**Do not forward 389/636 to the public internet.** If you need LAN clients to
|
||||
bind LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
|
||||
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
|
||||
SAN. The default shows the public SSO hostname, which implies a public route.
|
||||
|
||||
### 4. Docker + Docker Compose
|
||||
|
||||
Any recent Docker with Compose — the v2 plugin (`docker compose`) or the v1
|
||||
standalone (`docker-compose`) both work.
|
||||
You must use the modern Docker Compose v2 plugin (`docker compose`). The older
|
||||
v1 standalone (`docker-compose`) is not compatible with the BuildKit images
|
||||
generated by this suite and will fail with a `ContainerConfig` KeyError during
|
||||
deployment.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/theta42/theta-env.git
|
||||
cd theta-env
|
||||
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||
cd theta-suite
|
||||
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
|
||||
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
|
||||
```
|
||||
@@ -169,11 +185,21 @@ operator-owned and `setup.env` is ignored.
|
||||
would 404. Idempotent; skips a host that already exists.
|
||||
6. Prints your first admin login + the public URLs.
|
||||
|
||||
### Configuration — `./config/` (no `.env` files)
|
||||
### Configuration & secrets — OpenBao + `./config/`
|
||||
|
||||
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
|
||||
read by each app's `@simpleworkjs/conf` via the `CONF_SECRETS` env var, which
|
||||
the entrypoint points at the mounted file:
|
||||
Secrets live in **OpenBao** (a Vault fork, container `openbao:8200` on
|
||||
`theta-net`), the single authoritative store. Each app loads them at boot with
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||
deep-merges `secret/<app>/conf` over the file-loaded config — **fail-soft**, so
|
||||
if OpenBao is unreachable the app boots from the file fallback. End users get
|
||||
personal per-user secret storage (`secret/users/<uid>/*`) in the SSO **Vault**
|
||||
UI, and admins mint scoped tokens for external apps (`secret/apps/<name>/*`).
|
||||
See **[docs/secrets.md](docs/secrets.md)** for the full architecture, policies,
|
||||
token model, rotation, and the external-app convention.
|
||||
|
||||
A bind-mounted `./config/` directory (gitignored) holds the operator-edit
|
||||
seed files and the fail-soft fallback, read by each app's `@simpleworkjs/conf`
|
||||
via the `CONF_SECRETS` env var:
|
||||
|
||||
- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password,
|
||||
user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus
|
||||
@@ -184,11 +210,15 @@ the entrypoint points at the mounted file:
|
||||
same `serviceAccountPass`), `auth` (admin groups/users).
|
||||
|
||||
`./setup.sh` generates both on first run from `./setup.env` (the one place the
|
||||
domain is entered — see *Quickstart*) with random secrets. There is **no
|
||||
`.env` / `proxy.env`** — edit `./config/*.js` directly. Compose only interpolates
|
||||
port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can override on the
|
||||
command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See `config.example/` for the
|
||||
full annotated shape, and each submodule's `secrets.js.example`.
|
||||
domain is entered — see *Quickstart*) with random secrets, seeds them into
|
||||
OpenBao, and mints scoped per-app tokens (`SSO_VAULT_TOKEN` /
|
||||
`PROXY_VAULT_TOKEN` / `JUMP_VAULT_TOKEN`) into `./.env`. There is **no
|
||||
`.env` / `proxy.env`** for app config — edit `./config/*.js` directly (and
|
||||
re-seed into OpenBao, or use the SSO Configuration UI for live edits). Compose
|
||||
only interpolates port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can
|
||||
override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See
|
||||
`config.example/` for the full annotated shape, and each submodule's
|
||||
`secrets.js.example`.
|
||||
|
||||
> **Migrating from an older `.env`-based deployment?** If `.env` and/or
|
||||
> `proxy.env` exist when you first run `./setup.sh`, it migrates them into
|
||||
@@ -207,9 +237,10 @@ full annotated shape, and each submodule's `secrets.js.example`.
|
||||
- **Proxy mgmt UI**: `https://<PROXY_HOST>` — add the Host records you want to
|
||||
protect with OIDC. (First-run fallback: `http://<host>:3000`, reachable on the
|
||||
LAN by default.)
|
||||
- **Direct LDAP for legacy apps**: bind to `ldaps://<host>:636` as
|
||||
`cn=admin,<base>` (admin) or `cn=ldapclient,ou=people,<base>` (read-only
|
||||
service account the bootstrap created). Use LDAPS, not plain LDAP.
|
||||
- **Direct LDAP for LDAP-native clients and Linux hosts**: bind to
|
||||
`ldaps://<host>:636` as `cn=admin,<base>` (admin) or
|
||||
`cn=ldapclient,ou=people,<base>` (read-only service account the bootstrap
|
||||
created). Use LDAPS, not plain LDAP.
|
||||
|
||||
### API tokens (personal access tokens)
|
||||
|
||||
@@ -239,17 +270,19 @@ for details.
|
||||
|
||||
## Logs
|
||||
|
||||
The stack runs under Docker Compose with two services — `sso-manager` and
|
||||
`proxy`. Both the Node app and, for the SSO, OpenLDAP write to the container's
|
||||
stdout/stderr, so `docker compose logs` is the primary view.
|
||||
The stack runs under Docker Compose with several services — `sso-manager`,
|
||||
`proxy`, `jump-host`, and `openbao` (plus its `bao-renewer` sidecar). Both the
|
||||
Node app and, for the SSO, OpenLDAP write to the container's stdout/stderr, so
|
||||
`docker compose logs` is the primary view.
|
||||
|
||||
```bash
|
||||
# Follow both services live
|
||||
# Follow all services live
|
||||
docker compose logs -f
|
||||
|
||||
# One service
|
||||
docker compose logs -f sso-manager
|
||||
docker compose logs -f proxy
|
||||
docker compose logs -f jump-host
|
||||
|
||||
# Last 200 lines and keep following
|
||||
docker compose logs --tail=200 -f proxy
|
||||
@@ -317,8 +350,11 @@ docker compose cp sso-manager:/data/dump.rdb sso-manager.rdb
|
||||
docker compose exec proxy redis-cli BGSAVE
|
||||
docker compose cp proxy:/data/dump.rdb proxy.rdb
|
||||
|
||||
# Secrets
|
||||
# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative)
|
||||
cp -a ./config config-backup && chmod 700 config-backup
|
||||
# OpenBao (the authoritative secret store — back up its data volume)
|
||||
docker run --rm -v theta-suite_openbao-data:/data -v "$PWD":/backup alpine \
|
||||
tar czf /backup/openbao-data.tgz -C /data .
|
||||
```
|
||||
|
||||
### Restore — full disaster recovery
|
||||
@@ -369,30 +405,6 @@ Redis and are preserved by the volume.
|
||||
|
||||
---
|
||||
|
||||
## Running each project standalone
|
||||
|
||||
The two submodules work on their own — this repo just composes them:
|
||||
|
||||
- **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
|
||||
@@ -457,15 +469,18 @@ exactly in the bootstrap) so the SSO can verify them on bind.
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
theta-env/
|
||||
theta-suite/
|
||||
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN
|
||||
├── config.example/ # committed annotated config templates (copy to ./config/)
|
||||
├── docker-compose.yml # sso-manager + proxy on one bridge net
|
||||
├── docker-compose.yml # sso-manager + proxy + jump-host + openbao on one bridge net
|
||||
├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups)
|
||||
├── bootstrap/
|
||||
│ └── bootstrap.js # runs in the sso-manager container
|
||||
├── sso-manager-node/ # git submodule
|
||||
└── proxy/ # git submodule
|
||||
├── proxy/ # git submodule
|
||||
├── jump-host/ # git submodule
|
||||
├── ldap-client/ # git submodule (enrolls Linux hosts; also the opt-in ldap-test-host fixture)
|
||||
└── theta-agent/ # git submodule
|
||||
```
|
||||
|
||||
`./setup.sh` reads the gitignored `setup.env` on first run to generate the
|
||||
@@ -478,7 +493,7 @@ tagged release of each app, not whatever's most recently merged upstream. To
|
||||
lock to the pinned commits (offline rebuild, or a deliberate pin), run
|
||||
`SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-env release
|
||||
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-suite release
|
||||
(and each submodule's own `CHANGELOG.md` —
|
||||
[proxy](https://github.com/theta42/proxy/blob/master/CHANGELOG.md),
|
||||
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-env bootstrap — runs inside the sso-manager container to wire the
|
||||
* theta-suite bootstrap — runs inside the sso-manager container to wire the
|
||||
* proxy into a (fresh or existing) SSO Manager. Invoked by setup.sh:
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/bootstrap.js
|
||||
@@ -23,6 +23,13 @@
|
||||
* into the file (the sso-manager mounts ./config
|
||||
* read-write for this purpose).
|
||||
*
|
||||
* Generated creds are ALSO written into OpenBao (secret/proxy/conf and, when
|
||||
* the jump host is enabled, secret/jump-host/conf) so the proxy + jump host
|
||||
* load them from OpenBao at boot via @simpleworkjs/bao-conf. setup.sh passes
|
||||
* the root VAULT_TOKEN on this exec for that purpose. The OpenBao write is
|
||||
* fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the /config
|
||||
* file remains the fallback and bootstrap does not fail the bring-up over it.
|
||||
*
|
||||
* Idempotent: re-running converges to the ./config values. The LDAP service
|
||||
* account + admin passwords are reset to the file values on each run; the
|
||||
* OAuth client is created if missing. If proxy-secrets.js already holds a
|
||||
@@ -77,16 +84,80 @@ const HAS_USABLE_CREDS = EXISTING_ID && EXISTING_SECRET
|
||||
&& !PLACEHOLDER.test(EXISTING_ID) && !PLACEHOLDER.test(EXISTING_SECRET);
|
||||
|
||||
const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`;
|
||||
|
||||
// Per-host SSO (proxy routes/host_auth.js) calls back to
|
||||
// `https://<proxied-host>/__proxy_auth/callback` — a DIFFERENT URL for every
|
||||
// host the proxy fronts, all against this one OAuth client. Registering just
|
||||
// REDIRECT_URI above is what produced "400 redirect_uri is not registered for
|
||||
// this client" the moment a host's auth was set to SSO. The SSO's
|
||||
// redirectUriAllowed() supports `**` (any number of labels), so one pattern
|
||||
// covers the whole domain; `**.` does not match the bare apex, so register that
|
||||
// separately for a host served at the domain itself.
|
||||
//
|
||||
// A function, not a const: DOMAIN is declared further down this file, so
|
||||
// evaluating it here at module scope would hit the temporal dead zone.
|
||||
function proxyRedirectUris() {
|
||||
if (!DOMAIN) return [REDIRECT_URI];
|
||||
return [
|
||||
REDIRECT_URI,
|
||||
`https://**.${DOMAIN}/__proxy_auth/callback`,
|
||||
`https://${DOMAIN}/__proxy_auth/callback`,
|
||||
];
|
||||
}
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
const CLIENT_NAME = 'theta-proxy';
|
||||
|
||||
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
|
||||
const SVC_DN = `cn=ldapclient,ou=people,${BASE_DN}`;
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_sso_oauth_admin'];
|
||||
// god_admin is the global super group (docs/GROUPS.md §2); the bootstrapped
|
||||
// admin is its first member. app_sso_admin / app_sso_oauth_admin are the
|
||||
// per-console admin groups still used by the SSO UI. god_admin is nested into
|
||||
// the app_sso_* groups (and every resource's _admin group) by
|
||||
// docker-entrypoint.sh + api_directory_admin, so LDAP-level consumers (SSSD,
|
||||
// sudo) resolve it transitively.
|
||||
const ADMIN_GROUPS = ['god_admin', 'app_sso_admin', 'app_sso_oauth_admin'];
|
||||
|
||||
const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
|
||||
const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
|
||||
|
||||
// ── OpenBao (Vault) writes ───────────────────────────────────────────────────
|
||||
// bootstrap generates the proxy's + jump host's OAuth client creds and writes
|
||||
// them back into /config/*-secrets.js (the file fallback). It ALSO writes the
|
||||
// complete conf into OpenBao so the proxy + jump host load it from there at
|
||||
// boot via @simpleworkjs/bao-conf. setup.sh passes the root VAULT_TOKEN on this
|
||||
// exec. Fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the write
|
||||
// is skipped with a warning — the file remains the fallback and bootstrap does
|
||||
// not fail the bring-up over it.
|
||||
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||
const VAULT_TOKEN = process.env.VAULT_TOKEN || '';
|
||||
|
||||
// Re-require a /config module after its file has been rewritten on disk
|
||||
// (require caches the old contents otherwise).
|
||||
function freshRequire(p) {
|
||||
delete require.cache[require.resolve(p)];
|
||||
return require(p);
|
||||
}
|
||||
|
||||
// PUT (replace) the data at secret/data/<vaultPath> with `data`. Warn-only.
|
||||
async function baoPut(vaultPath, data) {
|
||||
if (!VAULT_TOKEN) { log('OpenBao: VAULT_TOKEN unset — skipping write of secret/' + vaultPath); return; }
|
||||
try {
|
||||
const res = await fetch(`${VAULT_ADDR}/v1/secret/data/${vaultPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log(`WARNING: OpenBao write secret/${vaultPath} failed (${res.status}) ${text} — app will use its file fallback`);
|
||||
} else {
|
||||
log(`OpenBao: wrote secret/${vaultPath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`WARNING: OpenBao write secret/${vaultPath} threw (${e.message}) — app will use its file fallback`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the
|
||||
// directory stores passwords the SSO can verify on bind (pw-sha2 module).
|
||||
function hashPasswordSSHA512(password) {
|
||||
@@ -127,31 +198,88 @@ function ldapModify(ldif) {
|
||||
}
|
||||
|
||||
// ── 1. LDAP service account for the proxy ───────────────────────────────────
|
||||
// The proxy / ldap-client bind as cn=ldapclient. For it to SHOW in the SSO Users
|
||||
// UI as a service account it must (a) match the user filter (posixAccount) and
|
||||
// (b) be a member of app_sso_service_account (that membership is what the Users
|
||||
// page marks as a non-person/service account). Older bootstraps created it as a
|
||||
// bare organizationalRole (invisible to the Users list) and never joined the
|
||||
// group, so it never appeared. Both are fixed here; the existing-path shape add
|
||||
// is best-effort so a pre-existing account still binds even if the upgrade add
|
||||
// fails.
|
||||
function ensureServiceAccount() {
|
||||
const pw = hashPasswordSSHA512(SVC_PASS);
|
||||
const uidNum = '10001'; // distinct from the bootstrap admin's 10000; above uidGidReservedFloor so regular-user id allocation ignores it
|
||||
if (entryExists(SVC_DN)) {
|
||||
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
|
||||
const r = ldapModify([
|
||||
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
|
||||
// Add the auxiliary posixAccount objectClass + required attrs so the entry
|
||||
// matches the Users list filter. inetOrgPerson is deliberately NOT added:
|
||||
// it is structural and would conflict with the existing organizationalRole.
|
||||
const shape = [
|
||||
`dn: ${SVC_DN}`,
|
||||
'changetype: modify',
|
||||
'add: objectClass',
|
||||
'objectClass: posixAccount',
|
||||
'-',
|
||||
'add: uid',
|
||||
'uid: ldapclient',
|
||||
'-',
|
||||
'add: uidNumber',
|
||||
`uidNumber: ${uidNum}`,
|
||||
'-',
|
||||
'add: gidNumber',
|
||||
`gidNumber: ${uidNum}`,
|
||||
'-',
|
||||
'add: homeDirectory',
|
||||
'homeDirectory: /nonexistent',
|
||||
'-',
|
||||
'add: description',
|
||||
'description: LDAP bind service account (proxy / ldap-client)',
|
||||
'',
|
||||
].join('\n');
|
||||
const rs = ldapModify(shape);
|
||||
if (rs.code !== 0 && !/already exists|Type or value exists/i.test(rs.stderr)) {
|
||||
log(' service-account shape warning (account still binds):', rs.stderr.trim());
|
||||
}
|
||||
const rp = ldapModify([
|
||||
`dn: ${SVC_DN}`,
|
||||
'changetype: modify',
|
||||
'replace: userPassword',
|
||||
`userPassword: ${pw}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
|
||||
return;
|
||||
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
|
||||
} else {
|
||||
log(`Creating service account ${SVC_DN}`);
|
||||
const entry = [
|
||||
`dn: ${SVC_DN}`,
|
||||
'objectClass: inetOrgPerson',
|
||||
'objectClass: posixAccount',
|
||||
'objectClass: top',
|
||||
'cn: ldapclient',
|
||||
'sn: ldapclient',
|
||||
'uid: ldapclient',
|
||||
`uidNumber: ${uidNum}`,
|
||||
`gidNumber: ${uidNum}`,
|
||||
'homeDirectory: /nonexistent',
|
||||
'description: LDAP bind service account (proxy / ldap-client)',
|
||||
`userPassword: ${pw}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = ldapAdd(entry);
|
||||
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
|
||||
}
|
||||
log(`Creating service account ${SVC_DN}`);
|
||||
const r = ldapAdd([
|
||||
`dn: ${SVC_DN}`,
|
||||
'objectClass: organizationalRole',
|
||||
'objectClass: simpleSecurityObject',
|
||||
'objectClass: top',
|
||||
'cn: ldapclient',
|
||||
`userPassword: ${pw}`,
|
||||
// Mark it as a service account (the Users UI's service-account signal).
|
||||
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
|
||||
const rm = ldapModify([
|
||||
`dn: ${gdn}`,
|
||||
'changetype: modify',
|
||||
'add: member',
|
||||
`member: ${SVC_DN}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
|
||||
if (rm.code === 0) log(` marked ${SVC_DN} as a service account`);
|
||||
else if (/already exists|Type or value exists/i.test(rm.stderr)) log(` ${SVC_DN} already in app_sso_service_account`);
|
||||
else log(` app_sso_service_account membership warning:`, rm.stderr.trim());
|
||||
}
|
||||
|
||||
// ── 2. First admin user ─────────────────────────────────────────────────────
|
||||
@@ -229,14 +357,15 @@ async function listClients(token) {
|
||||
return (data && data.results) || [];
|
||||
}
|
||||
|
||||
async function createClient(token) {
|
||||
async function createClient(token, opts) {
|
||||
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: proxyRedirectUris() };
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: CLIENT_NAME,
|
||||
description: 'theta-env proxy (auto-registered)',
|
||||
redirect_uris: [REDIRECT_URI],
|
||||
name: o.name,
|
||||
description: o.description,
|
||||
redirect_uris: o.redirect_uris,
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
allowed_groups: [],
|
||||
}),
|
||||
@@ -249,10 +378,33 @@ async function createClient(token) {
|
||||
const id = (data.results && data.results.client_id) || data.client_id;
|
||||
const secret = data.client_secret;
|
||||
if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`);
|
||||
log(`Created OAuth client ${CLIENT_NAME} (${id})`);
|
||||
log(`Created OAuth client ${o.name} (${id})`);
|
||||
return { id, secret };
|
||||
}
|
||||
|
||||
// Add any redirect_uris the client is missing, keeping whatever the operator
|
||||
// has already registered. Backfills installs whose proxy client was created
|
||||
// before the per-host `__proxy_auth/callback` patterns existed — without this,
|
||||
// setting a host's auth to SSO fails with "400 redirect_uri is not registered
|
||||
// for this client" on an upgraded stack and only works on a fresh one.
|
||||
// Warn-only: a stack that cannot widen its client is still a working stack.
|
||||
async function ensureRedirectUris(token, client, wanted) {
|
||||
const have = client.redirect_uris || [];
|
||||
const missing = wanted.filter((u) => !have.includes(u));
|
||||
if (!missing.length) return;
|
||||
try {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${client.client_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ redirect_uris: [...have, ...missing] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
|
||||
log(` OAuth client ${client.name}: registered ${missing.length} redirect URI(s) for per-host SSO`);
|
||||
} catch (error) {
|
||||
log(` WARNING: could not add redirect URIs to ${client.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateClient(token, id) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${id}/rotate`, {
|
||||
method: 'POST',
|
||||
@@ -268,6 +420,364 @@ async function rotateClient(token, id) {
|
||||
return { id, secret: data.client_secret };
|
||||
}
|
||||
|
||||
// ── 5. Seed the SSO directory with the stack's own resources ────────────────
|
||||
// The Directory page (site → host → service hierarchy) starts empty even
|
||||
// though this stack knows exactly what it deployed. Seed it: one site (the
|
||||
// domain), one host (the box this stack runs on), and the two services
|
||||
// (SSO Manager + proxy), then link the proxy's OAuth client under its
|
||||
// service. Idempotent — existing slugs are left untouched, so operator
|
||||
// edits (renames, metadata, extra resources) survive re-runs. Failures
|
||||
// here only warn: the directory is a nicety, never worth failing a
|
||||
// bring-up over (e.g. an older sso-manager image without /api/directory).
|
||||
const DOMAIN = (sso.stack && sso.stack.ldapDomain) || '';
|
||||
const ORG = sso.name || 'SSO Manager';
|
||||
|
||||
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
async function dirGet(token, path) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
|
||||
headers: { 'auth-token': token },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET /api/directory-admin/${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function dirPost(token, path, body) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`POST /api/directory-admin/${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function dirPut(token, path, body) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`PUT /api/directory-admin/${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function dirDelete(token, path) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'auth-token': token },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`DELETE /api/directory-admin/${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// The site the stack registers itself under. Also the default "Location
|
||||
// (Site)" that ldap-client-joined Linux hosts attach to (parent slug
|
||||
// site_<name> — see ldap-client/index.sh), so the slugs must line up.
|
||||
const SITE_NAME = (sso.stack && sso.stack.siteName) || 'local';
|
||||
|
||||
// Host facts, collected by setup.sh ON THE HOST (inside this container
|
||||
// hostname/uname describe the container) and passed via the exec env. Same
|
||||
// fields ldap-client/index.sh registers, so stack hosts and ldap-client-
|
||||
// joined hosts carry identical metadata.
|
||||
const HOST_FACTS = {
|
||||
name: process.env.STACK_HOST_NAME || '',
|
||||
ip: process.env.STACK_HOST_IP || '',
|
||||
mac: process.env.STACK_HOST_MAC || '',
|
||||
os: process.env.STACK_HOST_OS || '',
|
||||
kernel: process.env.STACK_HOST_KERNEL || '',
|
||||
};
|
||||
|
||||
async function seedDirectory(token, clientId, jumpClientId) {
|
||||
let resources = ((await dirGet(token, 'resources')).results) || [];
|
||||
// Tolerated separately from the resource list: edges only drive the
|
||||
// re-parent + OAuth-link steps, and losing those is not a reason to skip
|
||||
// seeding the resources themselves.
|
||||
let edges = [];
|
||||
try { edges = ((await dirGet(token, 'edges')).results) || []; }
|
||||
catch (e) { log(` WARNING: could not list directory edges (${e.message}) — skipping re-parent/link steps`); }
|
||||
|
||||
// Move an already-seeded resource under the parent it should have had.
|
||||
// Only ever corrects a parent this bootstrap itself seeded wrongly (the
|
||||
// proxy/jump services were parented to the stack host instead of to
|
||||
// host_theta-proxy / host_theta-jump); an operator who has deliberately
|
||||
// re-parented something keeps their layout, because we only rewire when the
|
||||
// current parent is the one the old code would have set.
|
||||
async function reparent(resource, wantParentId, fromParentId) {
|
||||
if (!resource || !wantParentId || !fromParentId) return;
|
||||
const current = edges.find((e) => e.childId === resource.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
||||
if (!current) return; // unparented: leave it alone
|
||||
if (current.parentId === wantParentId) return; // already correct
|
||||
if (current.parentId !== fromParentId) return; // operator moved it: respect that
|
||||
// PUT with kind + hostId is what makes the route rewire the parent edge.
|
||||
await dirPut(token, `resources/${resource.id}`, { kind: resource.kind, hostId: wantParentId });
|
||||
current.parentId = wantParentId;
|
||||
log(` directory: re-parented ${resource.kind} '${resource.slug}' onto its own host`);
|
||||
}
|
||||
|
||||
// Create a resource unless its slug (or a legacy alternate from an earlier
|
||||
// seed layout) already exists. On an existing resource, seed metadata keys
|
||||
// it doesn't have yet are filled in — operator-set values always win and
|
||||
// are never overwritten.
|
||||
async function ensure(kind, name, slug, parentId, metadata, altSlugs) {
|
||||
const slugs = [slug, ...(altSlugs || [])];
|
||||
const found = resources.find((r) => slugs.includes(r.slug));
|
||||
if (found) {
|
||||
const have = found.metadata || {};
|
||||
const missing = Object.entries(metadata || {})
|
||||
.filter(([k, v]) => (have[k] === undefined || have[k] === '') && v !== '');
|
||||
if (missing.length) {
|
||||
const merged = { ...have };
|
||||
for (const [k, v] of missing) merged[k] = v;
|
||||
// metadata-only PUT: no kind/hostId in the body, so the route's
|
||||
// parent validation and edge rewiring are not triggered.
|
||||
await dirPut(token, `resources/${found.id}`, { metadata: merged });
|
||||
found.metadata = merged;
|
||||
log(` directory: ${kind} '${found.slug}' exists — filled ${missing.map(([k]) => k).join(', ')}`);
|
||||
} else {
|
||||
log(` directory: ${kind} '${found.slug}' exists — keeping`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
const body = { kind, name, slug, metadata: metadata || {} };
|
||||
if (parentId) body.hostId = parentId; // POST creates the parent edge
|
||||
const created = (await dirPost(token, 'resources', body)).results;
|
||||
resources.push(created);
|
||||
log(` directory: created ${kind} '${slug}'`);
|
||||
return created;
|
||||
}
|
||||
|
||||
// site_<name> / host_<name> slug convention matches ldap-client/index.sh.
|
||||
// altSlugs grandfather in the layout the first seed release used.
|
||||
const site = await ensure('site', SITE_NAME, `site_${slugify(SITE_NAME)}`, null,
|
||||
{ isCurrentSite: true },
|
||||
[slugify(DOMAIN || ORG)]);
|
||||
const hostSlug = HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : 'stack-host';
|
||||
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
|
||||
subType: 'linux',
|
||||
ip: HOST_FACTS.ip,
|
||||
address: HOST_FACTS.ip,
|
||||
macAddress: HOST_FACTS.mac,
|
||||
os: HOST_FACTS.os,
|
||||
kernel: HOST_FACTS.kernel,
|
||||
sshPort: 22,
|
||||
managed: true,
|
||||
}, ['stack-host']);
|
||||
|
||||
// "Host" means a real, independently-existing machine — something with its
|
||||
// own OS and sshd, that theta-agent or a directory-aware tool like the jump
|
||||
// host could actually reach on its own. A Docker container backing one of
|
||||
// this stack's own services is never that, no matter how convenient it'd be
|
||||
// to group its services under a host-shaped node in the UI: it has no sshd,
|
||||
// no independent network identity, nothing jump-host could honestly offer
|
||||
// as an SSH target. Proxy and jump-host are two of this stack's five
|
||||
// containers, running on the one real host above (`host`) — not machines of
|
||||
// their own. Briefly (2026-08-05 through the next release) this file seeded
|
||||
// `host_theta-proxy` / `host_theta-jump` as first-class `kind: 'host'`
|
||||
// resources to fix their services being parented to the stack host; that
|
||||
// solved the parenting problem with the wrong tool. The right tool already
|
||||
// existed: `kind: 'container'` (see seedPlugins' Docker discovery, which
|
||||
// already attaches `docker-theta-suite-proxy` etc. under these services
|
||||
// correctly) sits one layer below `service`, same as `sso-manager` and
|
||||
// `openbao` already do. So: no synthetic hosts — Proxy's and jump-host's
|
||||
// services parent directly onto the stack host, same as everything else.
|
||||
|
||||
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
|
||||
address: `https://${SSO_HOST}`,
|
||||
port: 3001,
|
||||
gitRepo: 'https://github.com/theta42/sso-manager-node',
|
||||
subType: 'web',
|
||||
icon: 'mdi:shield-account',
|
||||
tagline: 'Home-lab identity and access management.',
|
||||
requestable: false,
|
||||
});
|
||||
// Proxy = the node management UI; OpenResty = the data plane every hostname
|
||||
// in the stack actually flows through (80/443). Two faces, two entries, both
|
||||
// parented directly to the stack host — see the "Host means..." note above.
|
||||
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
|
||||
address: `https://${PROXY_HOST}`,
|
||||
port: 3000,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
subType: 'web',
|
||||
icon: 'mdi:server-network',
|
||||
tagline: 'Reverse proxy and API gateway.',
|
||||
requestable: false,
|
||||
});
|
||||
// OpenLDAP is independently consumed — Linux hosts authenticate against it
|
||||
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
|
||||
// (see the SSO's /integrations page) — so it gets its own entry. Advertise
|
||||
// the operator-configured LDAPS hostname when set, else the SSO host.
|
||||
// The bundled slapd's image/config live in sso-manager-node.
|
||||
const LDAPS_HOST = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
|
||||
await ensure('service', 'OpenLDAP Directory', 'openldap', host.id, {
|
||||
address: `ldaps://${LDAPS_HOST}:636`,
|
||||
port: 389,
|
||||
externalPort: 636,
|
||||
portMappings: [{ proto: 'tcp', external: 636, internal: 389, comment: 'LDAPS' }],
|
||||
gitRepo: 'https://github.com/theta42/sso-manager-node',
|
||||
subType: 'openldap',
|
||||
icon: 'mdi:book-open-outline',
|
||||
tagline: 'LDAP directory for identity.',
|
||||
requestable: false,
|
||||
});
|
||||
// Wildcard address: OpenResty fronts every host under the domain (same
|
||||
// */** wildcard convention the proxy's Host records use). Its config lives
|
||||
// in the proxy repo (ops/nginx_conf).
|
||||
await ensure('service', 'OpenResty Edge', 'openresty', host.id, {
|
||||
address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`,
|
||||
port: 443,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
subType: 'openresty',
|
||||
icon: 'mdi:router-network',
|
||||
tagline: 'Data plane.',
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// Remove or set ignored on OpenBao/bao-renewer seed resources if present — OpenBao is an
|
||||
// internal stack service, not a user-facing published directory service.
|
||||
for (const r of resources) {
|
||||
if (r.slug === 'openbao' || r.slug === 'openboa' || r.slug === 'bao-renewer' || (r.name && (/openbao|openboa|bao-renewer/i.test(r.name)))) {
|
||||
await dirDelete(token, `resources/${r.id}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// SSH jump host service (core component — always registered).
|
||||
let jumpSvc = null;
|
||||
{
|
||||
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
|
||||
address: jumpHost ? `https://${jumpHost}` : '',
|
||||
port: 3002,
|
||||
gitRepo: 'https://github.com/theta42/jump-host',
|
||||
subType: 'ssh',
|
||||
icon: 'mdi:ssh',
|
||||
tagline: 'Secure SSH jump host.',
|
||||
requestable: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Correct installs seeded between 2026-08-05 and this release, where Proxy's
|
||||
// and jump-host's services were parented to now-removed synthetic
|
||||
// `host_theta-proxy` / `host_theta-jump` resources instead of the stack
|
||||
// host. Look them up by slug (never created going forward) rather than
|
||||
// `ensure`-ing them back into existence: on any install that never had
|
||||
// them, or already got corrected, this is a no-op.
|
||||
const proxyHostRes = resources.find((r) => r.slug === 'host_theta-proxy');
|
||||
const jumpHostRes = resources.find((r) => r.slug === 'host_theta-jump');
|
||||
if (proxyHostRes) {
|
||||
await reparent(psvc, host.id, proxyHostRes.id);
|
||||
await reparent(resources.find((r) => r.slug === 'openresty'), host.id, proxyHostRes.id);
|
||||
}
|
||||
if (jumpHostRes) {
|
||||
await reparent(jumpSvc, host.id, jumpHostRes.id);
|
||||
}
|
||||
|
||||
// Once childless, the synthetic host itself is dead weight from this file's
|
||||
// own earlier mistake — never something an operator would hand-create at
|
||||
// these exact reserved slugs — so remove it. DELETE /resources/:id clears
|
||||
// its own edges first, so this is safe now that the reparents above have
|
||||
// already moved the real children off of it.
|
||||
async function removeIfChildless(resource, label) {
|
||||
if (!resource) return;
|
||||
const stillHasChildren = edges.some((e) => e.parentId === resource.id);
|
||||
if (stillHasChildren) {
|
||||
log(` directory: '${label}' still has children after reparenting — leaving it for now`);
|
||||
return;
|
||||
}
|
||||
await dirDelete(token, `resources/${resource.id}`);
|
||||
log(` directory: removed now-empty synthetic host '${label}'`);
|
||||
}
|
||||
await removeIfChildless(proxyHostRes, 'host_theta-proxy');
|
||||
await removeIfChildless(jumpHostRes, 'host_theta-jump');
|
||||
|
||||
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
|
||||
// owning service, if it appears in the directory and isn't linked yet.
|
||||
async function linkOauthClient(id, parent, label) {
|
||||
if (!id || !parent) return;
|
||||
const oauthRes = resources.find((r) => r.id === id);
|
||||
if (!oauthRes) return;
|
||||
const linked = edges.some((e) => e.childId === id);
|
||||
if (!linked) {
|
||||
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
|
||||
edges.push({ parentId: parent.id, childId: id, relation: 'oauth' });
|
||||
log(` directory: linked OAuth client under '${label}'`);
|
||||
}
|
||||
}
|
||||
await linkOauthClient(clientId, psvc, 'proxy');
|
||||
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
|
||||
}
|
||||
|
||||
// ── Plugin instances ────────────────────────────────────────────────────────
|
||||
// Seed a sensible default set of plugin instances so the stack is usable the
|
||||
// moment it boots, without the operator having to add them by hand. The setup
|
||||
// stack runs on Docker, so the single biggest win is a Docker discovery plugin
|
||||
// pointed at the local daemon socket: containers that make up the stack (and
|
||||
// any others on the host) get discovered into the Directory automatically.
|
||||
// Idempotent per slug: an instance an operator already created is left alone.
|
||||
async function seedPlugins(token) {
|
||||
async function pluginGet(path) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/plugins/${path}`, {
|
||||
headers: { 'auth-token': token },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET /api/plugins/${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
async function pluginPost(body) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/plugins/`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`POST /api/plugins failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function ensurePlugin({ pluginType, name, slug, config }) {
|
||||
const existing = ((await pluginGet('')).results) || [];
|
||||
if (existing.some((i) => i.slug === slug)) {
|
||||
log(` plugins: '${slug}' exists — keeping`);
|
||||
return;
|
||||
}
|
||||
await pluginPost({ pluginType, name, slug, config });
|
||||
log(` plugins: created '${slug}' (${pluginType})`);
|
||||
}
|
||||
|
||||
try {
|
||||
// The Docker daemon the setup stack itself runs under. The socket must be
|
||||
// mounted into the sso container for discovery to reach it; if it isn't,
|
||||
// discovery simply errors non-fatally until it is.
|
||||
await ensurePlugin({
|
||||
pluginType: 'docker',
|
||||
name: 'Local Docker daemon',
|
||||
slug: 'docker-local',
|
||||
config: {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
// Containers in our own compose project are the stack itself --
|
||||
// already seeded as services above. Telling the plugin which
|
||||
// project that is lets it mark them managed and attach them to
|
||||
// the service they implement, instead of a fresh install
|
||||
// presenting its own five containers as unmanaged discoveries.
|
||||
stackProject: process.env.COMPOSE_PROJECT_NAME || 'theta-suite',
|
||||
hostSlug: HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
|
||||
}
|
||||
}
|
||||
|
||||
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
|
||||
// (which reads that file) can use them. Only the clientId/clientSecret lines
|
||||
// are touched; the rest of the file (operator edits, comments) is preserved.
|
||||
@@ -298,6 +808,219 @@ function writeProxyCreds(id, secret) {
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy needs a read-only SSO API token so its per-host SSO allow-list can
|
||||
// suggest the directory's actual groups (otherwise the "Allowed groups" field
|
||||
// autocompletes from the proxy's local groups only, which for an SSO-gated host
|
||||
// is never what the operator wants). Idempotent: only mints when the file's
|
||||
// `sso.apiToken` is still empty, and only rewrites that one line. Warn-only —
|
||||
// no token just means no suggestions.
|
||||
const PROXY_TOKEN_NAME = 'theta-proxy';
|
||||
|
||||
async function ensureProxyApiToken(token) {
|
||||
const path = '/config/proxy-secrets.js';
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(path, 'utf8');
|
||||
} catch (e) {
|
||||
log(` WARNING: cannot read ${path} to add an SSO API token (${e.message})`);
|
||||
return;
|
||||
}
|
||||
// An `sso: { ... apiToken: 'sso_...' }` already present means we're done.
|
||||
if (/apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src)) {
|
||||
log(' proxy already has an SSO API token — keeping');
|
||||
return;
|
||||
}
|
||||
if (!/\bsso:\s*\{/.test(src)) {
|
||||
log(` WARNING: ${path} has no \`sso\` block — add one with url + apiToken to enable SSO group autocomplete`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const apiToken = await mintApiToken(token, PROXY_TOKEN_NAME, 'theta-suite proxy (auto-registered)');
|
||||
// Replace the apiToken line inside the sso block only. The jump host's
|
||||
// token lives in a different file, so an unanchored match is safe here.
|
||||
const updated = src.replace(/(apiToken:\s*)(['"])[^'"]*\2/, `$1$2${apiToken}$2`);
|
||||
if (updated === src) {
|
||||
log(` WARNING: could not locate apiToken in ${path} — set sso.apiToken manually`);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(path, updated);
|
||||
log(` Minted SSO API token for the proxy and wrote it into ${path}`);
|
||||
} catch (e) {
|
||||
log(` WARNING: could not provision the proxy's SSO API token: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mint (or reuse) a theta-agent join key and hand it to setup.sh.
|
||||
//
|
||||
// A join key is the single credential an operator needs to add a host: the
|
||||
// agent presents it, the SSO enrolls the host and issues it its own per-agent
|
||||
// token + public key, which the agent writes back into its agent.yml. Without
|
||||
// this, adding a host meant pre-registering it in the SSO and copying two
|
||||
// values onto the machine by hand -- and setup.sh's own agent install had no
|
||||
// way to produce a token the server would accept at all.
|
||||
//
|
||||
// Idempotent: reuses the existing `setup` key rather than piling up new ones.
|
||||
// A key can only be shown once, so if the stored one is not recoverable we mint
|
||||
// a replacement and label it for the run that created it.
|
||||
async function ensureAgentJoinKey(token) {
|
||||
try {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/agent/join-keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: 'setup' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
|
||||
const data = await res.json();
|
||||
if (!data.key) throw new Error('join-key response had no key');
|
||||
log(' Minted a theta-agent join key');
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
log(` WARNING: could not mint a theta-agent join key: ${error.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Provision the SSH jump host ─────────────────────────────────────────
|
||||
// The jump host is a core component (always provisioned). It needs: a directory
|
||||
// API token (to resolve which hosts a user may reach), an LDAP bind account
|
||||
// that can WRITE the sshPublicKey attribute (it injects its own key on first
|
||||
// use), and a config file it reads. We write /config/jump-secrets.js deriving
|
||||
// LDAP/site from sso-secrets.js + a freshly minted API token. The bundled jump
|
||||
// host binds as cn=admin (already able to write sshPublicKey) — hardened
|
||||
// bare-metal deployments should use a scoped account + attribute ACL instead
|
||||
// (see the jump-host README). Idempotent: skips if the file already has a real
|
||||
// token.
|
||||
const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
const JUMP_SECRETS = '/config/jump-secrets.js';
|
||||
const JUMP_TOKEN_NAME = 'theta-jump-host';
|
||||
const JUMP_CLIENT_NAME = 'theta-jump';
|
||||
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
|
||||
|
||||
async function mintApiToken(token, name, description) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description: description || 'theta-suite jump host (auto-registered)' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
|
||||
const data = await res.json();
|
||||
const raw = data.token || (data.results && data.results.token) || data.raw_token;
|
||||
if (!raw) throw new Error(`API token response had no token: ${JSON.stringify(data)}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
// The generated file is "complete" only if it has BOTH a real directory API
|
||||
// token AND an OIDC client id — an existing file from the pre-OIDC layout (a
|
||||
// token but no oidc block) is regenerated so the web UI's SSO login works.
|
||||
function jumpFileComplete() {
|
||||
try {
|
||||
const src = fs.readFileSync(JUMP_SECRETS, 'utf8');
|
||||
const hasToken = /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
|
||||
const hasOidc = /clientId:\s*['"][0-9a-f-]{8,}['"]/.test(src);
|
||||
return hasToken && hasOidc;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function writeJumpSecrets(apiToken, oidc, localAdminPass) {
|
||||
const siteName = (sso.stack && sso.stack.siteName) || 'local';
|
||||
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
|
||||
const body = `'use strict';
|
||||
// Generated by theta-suite bootstrap. The jump host reads this via
|
||||
// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the
|
||||
// sshPublicKey attribute (key injection); for a hardened deployment use a
|
||||
// scoped account with an sshPublicKey write-ACL instead (see jump-host README).
|
||||
module.exports = {
|
||||
\tldap: {
|
||||
\t\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always
|
||||
\t\t// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts
|
||||
\t\t// treats a non-empty tlsOptions as "use implicit TLS" regardless of the
|
||||
\t\t// URL scheme -- pointed at the plain port, that means it opens a raw TLS
|
||||
\t\t// handshake against a server expecting plaintext LDAP, which slapd just
|
||||
\t\t// drops (logged as "connection lost", no BIND ever attempted). This bit
|
||||
\t\t// jump-host silently: every SSH login failed with the generic
|
||||
\t\t// "Permission denied" for any password, because getUser()/checkPassword()
|
||||
\t\t// never even reached slapd.
|
||||
\t\turl: 'ldaps://sso-manager:636',
|
||||
\t\tbindDN: ${JSON.stringify(BIND_DN)},
|
||||
\t\tbindPassword: ${JSON.stringify(ADMIN_PASS)},
|
||||
\t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)},
|
||||
\t\tgroupBase: ${JSON.stringify(`ou=groups,${BASE_DN}`)},
|
||||
\t\ttlsOptions: { rejectUnauthorized: false },
|
||||
\t},
|
||||
\tsso: {
|
||||
\t\turl: 'http://sso-manager:3001',
|
||||
\t\tapiToken: ${JSON.stringify(apiToken)},
|
||||
\t},
|
||||
\tssh: {
|
||||
\t\tlistenPort: 2222,
|
||||
\t\thostKeyPath: '/var/lib/jump-host/keys',
|
||||
\t\tpasswordAuth: 'off',
|
||||
\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)},
|
||||
\t},
|
||||
\tweb: { port: 3002 },
|
||||
\t// Web UI SSO login — the jump host's own OAuth client. tokenEndpoint /
|
||||
\t// userinfoEndpoint use the internal docker-net address (server-to-server);
|
||||
\t// authorizationEndpoint is the public SSO host (browser-facing).
|
||||
\toidc: {
|
||||
\t\tenabled: true,
|
||||
\t\tissuer: ${JSON.stringify(`https://${SSO_HOST}`)},
|
||||
\t\tauthorizationEndpoint: ${JSON.stringify(`https://${SSO_HOST}/oauth/authorize`)},
|
||||
\t\ttokenEndpoint: 'http://sso-manager:3001/oauth/token',
|
||||
\t\tuserinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
|
||||
\t\tclientId: ${JSON.stringify(oidc.id)},
|
||||
\t\tclientSecret: ${JSON.stringify(oidc.secret)},
|
||||
\t\tredirectUri: ${JSON.stringify(JUMP_REDIRECT_URI)},
|
||||
\t\tscopes: ['openid', 'profile', 'email', 'groups'],
|
||||
\t\tgroupsClaim: 'groups',
|
||||
\t\tusernameClaim: 'preferred_username',
|
||||
\t},
|
||||
\tauth: {
|
||||
\t\tadminGroups: ['app_sso_admin'],
|
||||
\t\tadminUsers: ['jumpadmin'],
|
||||
\t\tlocalAdminPass: ${JSON.stringify(localAdminPass)},
|
||||
\t},
|
||||
\tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } },
|
||||
\tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} },
|
||||
};
|
||||
`;
|
||||
fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Returns the jump host's OAuth client id (so seedDirectory can link it under
|
||||
// the SSH Jump Host service), whether or not this run actually wrote a fresh
|
||||
// jump-secrets.js -- otherwise re-runs on an already-configured deployment
|
||||
// never get a chance to self-heal a missing directory link (see the "no
|
||||
// parent" bug this was written for).
|
||||
async function provisionJumpHost(token) {
|
||||
if (jumpFileComplete()) {
|
||||
log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.');
|
||||
const clients = await listClients(token);
|
||||
const existing = clients.find((c) => c.name === JUMP_CLIENT_NAME);
|
||||
return existing ? existing.client_id : null;
|
||||
}
|
||||
const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME);
|
||||
|
||||
// Mint (or reuse) the jump host's own OAuth client for web-UI SSO login.
|
||||
const clients = await listClients(token);
|
||||
let oidc = clients.find((c) => c.name === JUMP_CLIENT_NAME);
|
||||
if (oidc && oidc.client_id) {
|
||||
oidc = await rotateClient(token, oidc.client_id);
|
||||
oidc = { id: oidc.id, secret: oidc.secret };
|
||||
} else {
|
||||
oidc = await createClient(token, {
|
||||
name: JUMP_CLIENT_NAME,
|
||||
description: 'theta-suite jump host web UI (auto-registered)',
|
||||
redirect_uris: [JUMP_REDIRECT_URI],
|
||||
});
|
||||
}
|
||||
|
||||
const localAdminPass = crypto.randomBytes(16).toString('hex');
|
||||
writeJumpSecrets(apiToken, oidc, localAdminPass);
|
||||
log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`);
|
||||
log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`);
|
||||
return oidc.id;
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
try {
|
||||
log(`Base DN: ${BASE_DN}`);
|
||||
@@ -307,10 +1030,15 @@ function writeProxyCreds(id, secret) {
|
||||
|
||||
const list = await listClients(token);
|
||||
// Find the proxy's client: by id if we have usable creds, else by name.
|
||||
let resolvedClientId = '';
|
||||
let client = null;
|
||||
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
|
||||
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
|
||||
|
||||
// Widen an existing client before any of the branches below return: a
|
||||
// freshly created one already gets these from createClient().
|
||||
if (client) await ensureRedirectUris(token, client, proxyRedirectUris());
|
||||
|
||||
if (client && HAS_USABLE_CREDS && client.client_id === EXISTING_ID) {
|
||||
// File creds match an existing client — trust the file's secret
|
||||
// (it's bcrypt-hashed server-side, so we can't verify, but the proxy
|
||||
@@ -319,6 +1047,7 @@ function writeProxyCreds(id, secret) {
|
||||
out('CLIENT_ID', EXISTING_ID);
|
||||
out('CLIENT_SECRET', EXISTING_SECRET);
|
||||
out('ALREADY_CONFIGURED', '1');
|
||||
resolvedClientId = EXISTING_ID;
|
||||
} else if (client) {
|
||||
// Client exists but the file has no recoverable secret for it — rotate
|
||||
// so the proxy gets a fresh secret it can actually read, then write back.
|
||||
@@ -328,6 +1057,7 @@ function writeProxyCreds(id, secret) {
|
||||
out('CLIENT_ID', id);
|
||||
out('CLIENT_SECRET', secret);
|
||||
out('ALREADY_CONFIGURED', '0');
|
||||
resolvedClientId = id;
|
||||
} else {
|
||||
// No client yet — create one and write the generated creds back.
|
||||
const { id, secret } = await createClient(token);
|
||||
@@ -335,7 +1065,61 @@ function writeProxyCreds(id, secret) {
|
||||
out('CLIENT_ID', id);
|
||||
out('CLIENT_SECRET', secret);
|
||||
out('ALREADY_CONFIGURED', '0');
|
||||
resolvedClientId = id;
|
||||
}
|
||||
|
||||
// Must run before the baoPut below: that snapshots proxy-secrets.js into
|
||||
// OpenBao, and the proxy loads its conf from there at boot, so a token
|
||||
// written after the snapshot would never reach the running proxy.
|
||||
await ensureProxyApiToken(token);
|
||||
|
||||
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
|
||||
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
|
||||
// fresh: writeProxyCreds rewrote the file out from under the cached
|
||||
// `proxy` object. setup.sh's seed already put a placeholder version
|
||||
// here; this replaces it with the complete file (operator edits +
|
||||
// generated OAuth creds). Warn-only.
|
||||
await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js'));
|
||||
|
||||
// Provision the jump host (mint token + write config). Warn-only — never
|
||||
// fail the whole bring-up over it, but it's a core component so always
|
||||
// attempted (no longer gated by CFG_JUMP_HOST_ENABLED).
|
||||
let jumpClientId = null;
|
||||
try {
|
||||
jumpClientId = await provisionJumpHost(token);
|
||||
out('JUMP_HOST_CONFIGURED', '1');
|
||||
// Mirror jump-secrets.js (just written by provisionJumpHost) into
|
||||
// OpenBao so the jump host loads it from there at boot via
|
||||
// @simpleworkjs/bao-conf. setup.sh's seed may have put a
|
||||
// placeholder/stale version here; this replaces it with the
|
||||
// complete file (LDAP bind, minted API token, OAuth client).
|
||||
// Warn-only.
|
||||
await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS));
|
||||
} catch (e) {
|
||||
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
|
||||
}
|
||||
|
||||
// The agent join key setup.sh writes into /etc/theta42/agent.yml.
|
||||
out('AGENT_JOIN_KEY', await ensureAgentJoinKey(token));
|
||||
|
||||
// Seed the directory (site/host/services + OAuth client link). Never
|
||||
// fails the bootstrap — warn and continue.
|
||||
try {
|
||||
log('Seeding directory resources...');
|
||||
await seedDirectory(token, resolvedClientId, jumpClientId);
|
||||
} catch (e) {
|
||||
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
|
||||
}
|
||||
|
||||
// Seed default plugin instances (Docker discovery) — same warn-and-go
|
||||
// policy; a stack without plugins is still usable.
|
||||
try {
|
||||
log('Seeding default plugins...');
|
||||
await seedPlugins(token);
|
||||
} catch (e) {
|
||||
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
|
||||
}
|
||||
|
||||
log('Done.');
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# seed-demo-users.sh — Seed realistic homelab/small-business demo users +
|
||||
# groups into the SSO Manager's LDAP directory, for screenshots/demos.
|
||||
#
|
||||
# Mirrors the schema sso-manager-node's addLdapUser/addGroup actually write
|
||||
# (see nodejs/models/user_ldap.js, group_ldap.js) so accounts created here are
|
||||
# indistinguishable from ones created through the UI. Idempotent: safe to
|
||||
# re-run, existing entries are skipped.
|
||||
#
|
||||
# Usage (from theta-env/):
|
||||
# docker compose exec -T sso-manager bash /bootstrap/seed-demo-users.sh
|
||||
#
|
||||
# Reads the real LDAP bind DN/password out of the mounted /config/sso-secrets.js
|
||||
# at runtime rather than hardcoding them, so it keeps working if secrets rotate.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LDAP_URL="ldap://localhost:389"
|
||||
BIND_DN=$(node -e "console.log(require('/config/sso-secrets.js').ldap.bindDN)")
|
||||
BIND_PW=$(node -e "console.log(require('/config/sso-secrets.js').ldap.bindPassword)")
|
||||
BASE_DN=$(node -e "console.log(require('/config/sso-secrets.js').stack.ldapBaseDn)")
|
||||
PEOPLE_OU="ou=people,${BASE_DN}"
|
||||
GROUPS_OU="ou=groups,${BASE_DN}"
|
||||
|
||||
info() { echo "[INFO] $*"; }
|
||||
error() { echo "[ERROR] $*" >&2; }
|
||||
|
||||
ldap_exists() {
|
||||
ldapsearch -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" -b "$1" -s base '(objectClass=*)' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
hash_password() {
|
||||
node -e "
|
||||
const crypto = require('crypto');
|
||||
const salt = crypto.randomBytes(8);
|
||||
const hash = crypto.createHash('sha512').update('$1').update(salt).digest();
|
||||
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
|
||||
"
|
||||
}
|
||||
|
||||
# create_person <uid> <sn> <given_name> <mail> <uidNumber> <password> [description]
|
||||
create_person() {
|
||||
local uid="$1" sn="$2" given="$3" mail="$4" uidnum="$5" pass="$6" desc="${7:-}"
|
||||
local person_dn="cn=${uid},${PEOPLE_OU}"
|
||||
local group_dn="cn=${uid},${GROUPS_OU}"
|
||||
|
||||
if ldap_exists "$person_dn"; then
|
||||
info "User '${uid}' already exists — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local hash; hash=$(hash_password "$pass")
|
||||
local tmp; tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' RETURN
|
||||
|
||||
cat > "$tmp" <<LDIF
|
||||
dn: ${group_dn}
|
||||
objectClass: posixGroup
|
||||
objectClass: top
|
||||
cn: ${uid}
|
||||
gidNumber: ${uidnum}
|
||||
description: Personal group for ${uid}
|
||||
|
||||
dn: ${person_dn}
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: sudoRole
|
||||
objectClass: ldapPublicKey
|
||||
objectClass: top
|
||||
objectClass: theta42Person
|
||||
cn: ${uid}
|
||||
sn: ${sn}
|
||||
givenName: ${given}
|
||||
uid: ${uid}
|
||||
uidNumber: ${uidnum}
|
||||
gidNumber: ${uidnum}
|
||||
homeDirectory: /home/${uid}
|
||||
loginShell: /bin/bash
|
||||
mail: ${mail}
|
||||
userPassword: ${hash}
|
||||
description: ${desc:- }
|
||||
sudoHost: ALL
|
||||
sudoCommand: ALL
|
||||
sudoUser: ${uid}
|
||||
LDIF
|
||||
|
||||
ldapadd -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" -f "$tmp"
|
||||
info "Created user '${uid}' (${mail})"
|
||||
}
|
||||
|
||||
# create_group <cn> <owner_dn> <description>
|
||||
create_group() {
|
||||
local cn="$1" owner_dn="$2" desc="$3"
|
||||
local group_dn="cn=${cn},${GROUPS_OU}"
|
||||
|
||||
if ldap_exists "$group_dn"; then
|
||||
info "Group '${cn}' already exists — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
ldapadd -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" <<LDIF
|
||||
dn: ${group_dn}
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: ${cn}
|
||||
description: ${desc}
|
||||
member: ${owner_dn}
|
||||
LDIF
|
||||
info "Created group '${cn}'"
|
||||
}
|
||||
|
||||
# add_member <group_cn> <user_dn>
|
||||
add_member() {
|
||||
local cn="$1" user_dn="$2"
|
||||
local group_dn="cn=${cn},${GROUPS_OU}"
|
||||
ldapmodify -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" 2>/dev/null <<LDIF || true
|
||||
dn: ${group_dn}
|
||||
changetype: modify
|
||||
add: member
|
||||
member: ${user_dn}
|
||||
LDIF
|
||||
}
|
||||
|
||||
info "Waiting for LDAP at ${LDAP_URL}..."
|
||||
for i in $(seq 1 30); do
|
||||
ldapsearch -x -H "$LDAP_URL" -b '' -s base '(objectClass=*)' >/dev/null 2>&1 && break
|
||||
[ "$i" -eq 30 ] && { error "LDAP not reachable"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Demo users (homelab / small-business cast) ───────────────────────────────
|
||||
# uidNumbers start at 5000 to stay well clear of the app's own auto-assigned
|
||||
# range (nextPosixId scans existing entries and increments from the highest).
|
||||
# See docs/fixtures.md for the canonical list this mirrors — update both
|
||||
# together.
|
||||
create_person schen Chen Sarah sarah.chen@laptop-dev.vm42.us 5000 'DemoPass123!' 'Engineering — DevOps lead'
|
||||
create_person dkim Kim David david.kim@laptop-dev.vm42.us 5001 'DemoPass123!' 'Engineering — Backend developer'
|
||||
create_person ppatel Patel Priya priya.patel@laptop-dev.vm42.us 5002 'DemoPass123!' 'Engineering — Frontend developer'
|
||||
create_person mjohnson Johnson Marcus marcus.johnson@laptop-dev.vm42.us 5003 'DemoPass123!' 'Finance — Finance manager'
|
||||
create_person lnguyen Nguyen Linda linda.nguyen@laptop-dev.vm42.us 5004 'DemoPass123!' 'Finance — Bookkeeper'
|
||||
create_person erodriguez Rodriguez Emily emily.rodriguez@laptop-dev.vm42.us 5005 'DemoPass123!' 'Support — Support lead'
|
||||
create_person tbaker Baker Tom tom.baker@laptop-dev.vm42.us 5006 'DemoPass123!' 'Support — Support tech'
|
||||
create_person jwilson Wilson James james.wilson@laptop-dev.vm42.us 5007 'DemoPass123!' 'Management — Owner'
|
||||
create_person svc-monitoring Bot monitoring monitoring@laptop-dev.vm42.us 5008 'ServiceAcct!2024' 'Service account — Grafana/Prometheus scraping'
|
||||
create_person svc-backup Bot backup backup@laptop-dev.vm42.us 5009 'ServiceAcct!2024' 'Service account — backup automation'
|
||||
|
||||
# ── Department groups (groupOfNames — what shows up in Directory > Groups) ──
|
||||
ADMIN_DN="cn=admin,${PEOPLE_OU}"
|
||||
create_group engineering "$ADMIN_DN" "Engineering team"
|
||||
create_group finance "$ADMIN_DN" "Finance and accounting"
|
||||
create_group support "$ADMIN_DN" "Support and operations"
|
||||
create_group management "$ADMIN_DN" "Company management"
|
||||
|
||||
add_member engineering "cn=schen,${PEOPLE_OU}"
|
||||
add_member engineering "cn=dkim,${PEOPLE_OU}"
|
||||
add_member engineering "cn=ppatel,${PEOPLE_OU}"
|
||||
add_member finance "cn=mjohnson,${PEOPLE_OU}"
|
||||
add_member finance "cn=lnguyen,${PEOPLE_OU}"
|
||||
add_member support "cn=erodriguez,${PEOPLE_OU}"
|
||||
add_member support "cn=tbaker,${PEOPLE_OU}"
|
||||
add_member management "cn=jwilson,${PEOPLE_OU}"
|
||||
|
||||
# Mark the service accounts as service accounts (app_sso_service_account is
|
||||
# seeded by the app itself on boot, so it should already exist).
|
||||
if ldap_exists "cn=app_sso_service_account,${GROUPS_OU}"; then
|
||||
add_member app_sso_service_account "cn=svc-monitoring,${PEOPLE_OU}"
|
||||
add_member app_sso_service_account "cn=svc-backup,${PEOPLE_OU}"
|
||||
else
|
||||
info "app_sso_service_account group not found — skipping service-account tagging"
|
||||
fi
|
||||
|
||||
info "Demo data seed complete."
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-suite site-join — runs inside the sso-manager container to adopt a
|
||||
* master site's directory as a read-only spoke. Invoked by setup.sh when
|
||||
* setup.env sets CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY:
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/site-join.js \
|
||||
* https://sso.master.example.com stj_9f2e... https://sso.this-site.example.com
|
||||
*
|
||||
* The third argument (selfUrl, optional) is this site's own public SSO host
|
||||
* (setup.sh passes https://$CFG_SSO_HOST) -- without it the join still
|
||||
* succeeds, it just registers for one-time adoption only: the master has no
|
||||
* way to reach this spoke to push live replication resync pings at it (see
|
||||
* theta-directory's docs/site-join.md and utils/site_replicate.js).
|
||||
*
|
||||
* Self-contained (Node built-ins + global fetch), same rule as bootstrap.js —
|
||||
* it does NOT require the SSO's internal models. It logs in as the bootstrap
|
||||
* admin (reading /config/sso-secrets.js) and calls the SSO's own
|
||||
* /api/site/join, which imports the master's resource catalog + LDAP tree and
|
||||
* persists the spoke role in /config/site.json.
|
||||
*
|
||||
* Output (stdout, KEY=VALUE for setup.sh): JOINED, SITE_SLUG, RESOURCES, LDAP.
|
||||
* Progress logs go to stderr.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const sso = require('/config/sso-secrets.js');
|
||||
|
||||
const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
|
||||
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || '';
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
|
||||
const masterUrl = process.argv[2];
|
||||
const joinKey = process.argv[3];
|
||||
const selfUrl = process.argv[4] || '';
|
||||
|
||||
function log(msg) { console.error('[site-join] ' + msg); }
|
||||
|
||||
async function main() {
|
||||
if (!masterUrl || !joinKey) {
|
||||
throw new Error('usage: node /bootstrap/site-join.js <masterUrl> <joinKey>');
|
||||
}
|
||||
|
||||
// 1. Login as the bootstrap admin (validates the password end-to-end).
|
||||
const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_USER_PASS }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
throw new Error(`admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||
}
|
||||
const loginData = await loginRes.json();
|
||||
const token = loginData.token;
|
||||
if (!token) throw new Error('admin login returned no token');
|
||||
log(`Logged in as ${ADMIN_UID}`);
|
||||
|
||||
// 2. Join the master.
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/site/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterUrl, joinKey, ...(selfUrl ? { selfUrl } : {}) }),
|
||||
});
|
||||
const text = await res.text().catch(() => '');
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||
if (!res.ok) {
|
||||
// A node that already joined is a no-op, not a failure (idempotent setup).
|
||||
if (res.status === 400 && data && /already a spoke/i.test(data.message || '')) {
|
||||
log('Already a spoke — nothing to do.');
|
||||
console.log('JOINED=already');
|
||||
return;
|
||||
}
|
||||
throw new Error(`join failed (${res.status}): ${(data && data.message) || text}`);
|
||||
}
|
||||
|
||||
log(`Joined master site ${masterUrl} as ${data.siteSlug || '?'}`);
|
||||
log(`Live replication: ${(data.replication && data.replication.note) || 'unknown'}`);
|
||||
console.log([
|
||||
`JOINED=yes`,
|
||||
`SITE_SLUG=${data.siteSlug || ''}`,
|
||||
`RESOURCES=${(data.resources && data.resources.created) || 0}`,
|
||||
`LDAP=${(data.ldap && data.ldap.note) || ''}`,
|
||||
`LIVE_REPLICATION=${(data.replication && data.replication.live) ? 'yes' : 'no'}`
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[site-join] FAILED: ' + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-suite site-ldap-register — runs inside the sso-manager container on
|
||||
* every setup.sh run (both master and spoke) to keep OpenLDAP N-way
|
||||
* multi-master replication config (docs/replication.md) in sync without an
|
||||
* operator hand-maintaining LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS.
|
||||
*
|
||||
* The master assigns each spoke a unique LDAP_SERVER_ID at join time (same
|
||||
* mechanism as jump-host's WireGuard mesh index) and derives every site's
|
||||
* LDAP URL from its already-known HTTPS endpoint -- see sso-manager-node's
|
||||
* GET /api/site/ldap-peers (spoke-facing) and
|
||||
* GET /directory-admin/ldap-replication-config (master-local).
|
||||
*
|
||||
* This script fetches whichever of those two applies to this node's role,
|
||||
* and writes the result to /config/ldap-replication.env (KEY=VALUE, the
|
||||
* same shape setup.env/spoke.env use) if it changed since last run. setup.sh
|
||||
* sources that file before starting sso-manager on every invocation, and
|
||||
* restarts the container when this script reports a change -- OpenLDAP's
|
||||
* static slapd.conf is only read at process start, so a config change needs
|
||||
* a restart to take effect; there's no live push, which is why this has to
|
||||
* be re-run periodically (every setup.sh invocation) rather than working
|
||||
* once at join time and never again, especially on the MASTER, whose peer
|
||||
* list changes every time a new spoke joins.
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/site-ldap-register.js <selfUrl>
|
||||
*
|
||||
* Self-contained (Node built-ins + global fetch), same rule as
|
||||
* bootstrap.js/site-join.js -- does NOT require the SSO's internal models.
|
||||
*
|
||||
* Output (stdout, KEY=VALUE for setup.sh): LDAP_CONFIG_CHANGED=<yes|no>,
|
||||
* LDAP_SERVER_ID=<n>, LDAP_REPLICATION_HOSTS=<space-separated, may be empty>.
|
||||
* Progress logs go to stderr.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SITE_CONFIG = '/config/site.json';
|
||||
const LDAP_CONFIG_FILE = '/config/ldap-replication.env';
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
|
||||
const selfUrl = process.argv[2];
|
||||
|
||||
function log(msg) { console.error('[site-ldap-register] ' + msg); }
|
||||
|
||||
function readPersisted() {
|
||||
if (!fs.existsSync(LDAP_CONFIG_FILE)) return { LDAP_SERVER_ID: '', LDAP_REPLICATION_HOSTS: '' };
|
||||
const out = { LDAP_SERVER_ID: '', LDAP_REPLICATION_HOSTS: '' };
|
||||
for (const line of fs.readFileSync(LDAP_CONFIG_FILE, 'utf8').split('\n')) {
|
||||
const m = line.match(/^([A-Z_]+)=(.*)$/);
|
||||
if (m && m[1] in out) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchMasterConfig() {
|
||||
const sso = require('/config/sso-secrets.js');
|
||||
const adminUid = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
|
||||
const adminPass = (sso.bootstrap && sso.bootstrap.adminPass) || '';
|
||||
|
||||
const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uid: adminUid, password: adminPass }),
|
||||
});
|
||||
if (!loginRes.ok) throw new Error(`local admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||
const { token } = await loginRes.json();
|
||||
if (!token) throw new Error('local admin login returned no token');
|
||||
|
||||
const cfgRes = await fetch(`${SSO_INTERNAL}/api/directory-admin/ldap-replication-config`, {
|
||||
headers: { 'auth-token': token },
|
||||
});
|
||||
if (!cfgRes.ok) throw new Error(`ldap-replication-config failed (${cfgRes.status}): ${await cfgRes.text().catch(() => '')}`);
|
||||
return cfgRes.json();
|
||||
}
|
||||
|
||||
async function fetchSpokeConfig(site, selfUrl) {
|
||||
const url = `${site.masterUrl.replace(/\/+$/, '')}/api/site/ldap-peers?endpoint=${encodeURIComponent(selfUrl)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: 'Bearer ' + site.masterJoinKey } });
|
||||
const text = await res.text().catch(() => '');
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
log('This site is not registered as a spoke on the master yet (join with selfUrl, or re-run site-relay-register.js). Skipping.');
|
||||
return null;
|
||||
}
|
||||
throw new Error(`ldap-peers failed (${res.status}): ${(data && data.message) || text}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(SITE_CONFIG)) {
|
||||
log('No /config/site.json yet. Skipping.');
|
||||
console.log('LDAP_CONFIG_CHANGED=no');
|
||||
return;
|
||||
}
|
||||
const site = JSON.parse(fs.readFileSync(SITE_CONFIG, 'utf8'));
|
||||
|
||||
let result;
|
||||
if (site.isMaster) {
|
||||
result = await fetchMasterConfig();
|
||||
} else {
|
||||
if (!site.masterUrl || !site.masterJoinKey) {
|
||||
log('Spoke role but missing masterUrl/masterJoinKey. Skipping.');
|
||||
console.log('LDAP_CONFIG_CHANGED=no');
|
||||
return;
|
||||
}
|
||||
if (!selfUrl) throw new Error('usage: node /bootstrap/site-ldap-register.js <selfUrl> (required for a spoke)');
|
||||
result = await fetchSpokeConfig(site, selfUrl);
|
||||
if (!result) {
|
||||
console.log('LDAP_CONFIG_CHANGED=no');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const serverId = String(result.ldapServerId || '');
|
||||
const hosts = (result.peers || []).map((p) => p.ldapHost).filter(Boolean).join(' ');
|
||||
|
||||
const before = readPersisted();
|
||||
const changed = before.LDAP_SERVER_ID !== serverId || before.LDAP_REPLICATION_HOSTS !== hosts;
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(LDAP_CONFIG_FILE, `LDAP_SERVER_ID=${serverId}\nLDAP_REPLICATION_HOSTS=${hosts}\n`);
|
||||
log(`Replication config changed -- ServerID ${serverId}, ${(result.peers || []).length} peer(s). Wrote ${LDAP_CONFIG_FILE}.`);
|
||||
} else {
|
||||
log(`Replication config unchanged -- ServerID ${serverId}, ${(result.peers || []).length} peer(s).`);
|
||||
}
|
||||
|
||||
console.log(`LDAP_CONFIG_CHANGED=${changed ? 'yes' : 'no'}`);
|
||||
console.log(`LDAP_SERVER_ID=${serverId}`);
|
||||
console.log(`LDAP_REPLICATION_HOSTS=${hosts}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[site-ldap-register] FAILED: ' + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-suite site-relay-register — runs inside the sso-manager container
|
||||
* (same pattern as site-join.js) to finish no-inbound relay automation for a
|
||||
* spoke with no public IP (MULTI_SITE_SPEC.md §5.2).
|
||||
*
|
||||
* site-join.js's initial join can't supply a mesh IP: this site's jump-host
|
||||
* isn't meshed to the master's yet at that point (mesh peering is a manual,
|
||||
* out-of-band action on both jump-hosts -- mint a join token on the master's
|
||||
* jump-host, paste it into this site's jump-host "Join a mesh" UI action --
|
||||
* the same reason the site join key itself is minted/pasted by hand rather
|
||||
* than automated). This script is the follow-up: run it (setup.sh does, on
|
||||
* every run, when CFG_SPOKE_NO_INBOUND is set) once meshing is done, and it
|
||||
* discovers this jump-host's mesh IP and registers it with the master so
|
||||
* theta-proxy there can auto-create the relay route (see sso-manager-node's
|
||||
* utils/proxy_client.js). Safe to run before meshing completes -- reports
|
||||
* "not meshed yet" and exits 0 so a re-run later just picks it up.
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/site-relay-register.js \
|
||||
* https://sso.this-site.example.com sso-branch2.master-domain.example.com
|
||||
*
|
||||
* Self-contained (Node built-ins + global fetch), same rule as bootstrap.js
|
||||
* and site-join.js -- it does NOT require the SSO's internal models. It
|
||||
* reads this node's own spoke role from /config/site.json (written by
|
||||
* site-join.js) and logs into the LOCAL jump-host as its bootstrap-minted
|
||||
* local admin (/config/jump-secrets.js) to call jump-host's own
|
||||
* GET /api/mesh/self.
|
||||
*
|
||||
* Output (stdout, KEY=VALUE for setup.sh): RELAY=<registered|not-meshed|not-a-spoke|skipped>.
|
||||
* Progress logs go to stderr.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SITE_CONFIG = '/config/site.json';
|
||||
const JUMP_SECRETS = '/config/jump-secrets.js';
|
||||
const JUMP_INTERNAL = 'http://jump-host:3002';
|
||||
|
||||
const selfUrl = process.argv[2];
|
||||
const publicHost = process.argv[3];
|
||||
|
||||
function log(msg) { console.error('[site-relay-register] ' + msg); }
|
||||
|
||||
async function main() {
|
||||
if (!selfUrl || !publicHost) {
|
||||
throw new Error('usage: node /bootstrap/site-relay-register.js <selfUrl> <publicHost>');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SITE_CONFIG)) {
|
||||
log('No /config/site.json yet — this node has not joined a master. Nothing to do.');
|
||||
console.log('RELAY=not-a-spoke');
|
||||
return;
|
||||
}
|
||||
const site = JSON.parse(fs.readFileSync(SITE_CONFIG, 'utf8'));
|
||||
if (site.isMaster || !site.masterUrl || !site.masterJoinKey) {
|
||||
log('Not a joined spoke (missing masterUrl/masterJoinKey, or this is a master). Nothing to do.');
|
||||
console.log('RELAY=not-a-spoke');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(JUMP_SECRETS)) {
|
||||
log('No /config/jump-secrets.js — jump-host has not been provisioned yet. Skipping.');
|
||||
console.log('RELAY=skipped');
|
||||
return;
|
||||
}
|
||||
const jumpSecrets = require(JUMP_SECRETS);
|
||||
const jumpAdminUser = (jumpSecrets.auth && jumpSecrets.auth.adminUsers && jumpSecrets.auth.adminUsers[0]) || 'jumpadmin';
|
||||
const jumpAdminPass = (jumpSecrets.auth && jumpSecrets.auth.localAdminPass) || '';
|
||||
if (!jumpAdminPass) {
|
||||
log('jump-secrets.js has no local admin password. Skipping.');
|
||||
console.log('RELAY=skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
const loginRes = await fetch(`${JUMP_INTERNAL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// jump-host's login route (@simpleworkjs/oidc-client's shared router)
|
||||
// expects `username`, not `uid` -- unlike sso-manager-node's own
|
||||
// /api/auth/login (see site-join.js). Confirmed against a real running
|
||||
// jump-host container; `uid` here just silently 401s.
|
||||
body: JSON.stringify({ username: jumpAdminUser, password: jumpAdminPass }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
throw new Error(`jump-host admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||
}
|
||||
const { token: jumpToken } = await loginRes.json();
|
||||
if (!jumpToken) throw new Error('jump-host login returned no token');
|
||||
|
||||
const selfRes = await fetch(`${JUMP_INTERNAL}/api/mesh/self`, { headers: { 'auth-token': jumpToken } });
|
||||
if (!selfRes.ok) {
|
||||
throw new Error(`jump-host mesh self-lookup failed (${selfRes.status}): ${await selfRes.text().catch(() => '')}`);
|
||||
}
|
||||
const selfData = await selfRes.json();
|
||||
if (!selfData.meshIp) {
|
||||
log('jump-host is not meshed yet (no mesh IP assigned). Mesh-join it first (jump-host UI), then re-run setup.sh.');
|
||||
console.log('RELAY=not-meshed');
|
||||
return;
|
||||
}
|
||||
log(`Discovered mesh IP ${selfData.meshIp}. Registering with ${site.masterUrl}...`);
|
||||
|
||||
const regRes = await fetch(`${site.masterUrl.replace(/\/+$/, '')}/api/site/spokes`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + site.masterJoinKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: selfUrl,
|
||||
siteSlug: site.siteSlug || '',
|
||||
noInbound: true,
|
||||
meshIp: selfData.meshIp,
|
||||
publicHost,
|
||||
}),
|
||||
});
|
||||
const text = await regRes.text().catch(() => '');
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||
if (!regRes.ok) {
|
||||
throw new Error(`relay registration failed (${regRes.status}): ${(data && data.message) || text}`);
|
||||
}
|
||||
|
||||
log(`Relay: ${(data.relay && data.relay.note) || 'registered'}`);
|
||||
console.log('RELAY=registered');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[site-relay-register] FAILED: ' + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
# ldap-client config for the optional local jump-host test fixture
|
||||
# (ldap-test-host service in docker-compose.yml, jump-host compose profile).
|
||||
# Copy to ./config/ldap-test-host.vars and fill in the bind password from
|
||||
# your own ./config/sso-secrets.js's `serviceAccountPass` (the
|
||||
# cn=ldapclient,ou=people,<base> service account bootstrap/bootstrap.js
|
||||
# creates specifically for this kind of 3rd-party/container LDAP bind).
|
||||
#
|
||||
# This is what lets ldap-test-host be a REAL SSSD+AuthorizedKeysCommand-joined
|
||||
# downstream host, so jump-host's key-injection -> upstream-connect flow can
|
||||
# be exercised end-to-end against something more than a container with a
|
||||
# manually-dropped public key in authorized_keys.
|
||||
export ldap_host="sso-manager"
|
||||
export ldap_base_dn="dc=localtest,dc=me"
|
||||
|
||||
export ldap_bind_dn="cn=ldapclient,ou=People,$ldap_base_dn"
|
||||
export ldap_bind_password="REPLACE_WITH_serviceAccountPass_FROM_sso-secrets.js"
|
||||
|
||||
# sso_url/sso_token deliberately left unset -- register the host + access
|
||||
# group manually via the Directory admin API instead (index.sh's optional
|
||||
# auto-registration also wants a parent site Resource to exist first).
|
||||
# index.sh gates that block on `[[ -v sso_token ]]`, which is true even for
|
||||
# an empty string, so leave these genuinely absent, not "".
|
||||
|
||||
export ldap_location="jumptest"
|
||||
|
||||
ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )
|
||||
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
// Example proxy secrets for the theta-env unified stack. Copy to
|
||||
// Example proxy secrets for the theta-suite unified stack. Copy to
|
||||
// ./config/proxy-secrets.js (NOT this file — ./config/ is gitignored) and edit.
|
||||
// `./setup.sh` generates ./config/proxy-secrets.js for you on first run and the
|
||||
// bootstrap writes the OAuth client clientId/clientSecret back into it; this
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
// Example SSO secrets for the theta-env unified stack. Copy to
|
||||
// Example SSO secrets for the theta-suite unified stack. Copy to
|
||||
// ./config/sso-secrets.js (NOT this file — ./config/ is gitignored) and edit.
|
||||
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
|
||||
// documents the shape for manual editing / reference.
|
||||
@@ -17,6 +17,9 @@ module.exports = {
|
||||
bindPassword: 'CHANGE-ME', // slapd root + app bind password
|
||||
userBase: 'ou=people,dc=example,dc=com',
|
||||
groupBase: 'ou=groups,dc=example,dc=com',
|
||||
// ldapsHost: 'ldap.internal.example.com', // optional: internal-only hostname
|
||||
// shown on /integrations for direct LDAPS binds. Empty -> derive from issuer.
|
||||
// ldapsPort: 636,
|
||||
},
|
||||
smtp: { // optional; leave host '' to skip
|
||||
host: '', port: 587, secure: false,
|
||||
@@ -27,6 +30,16 @@ module.exports = {
|
||||
jwtSecret: 'CHANGE-ME', // signs all tokens — keep secret
|
||||
token_lifetime: { access_token: 3600, refresh_token: 2592000 },
|
||||
},
|
||||
// Without this, @simpleworkjs/orm falls back to './config/inventory.sqlite'
|
||||
// (relative to the app's /app cwd) -- inside the container's ephemeral
|
||||
// layer, not any mounted volume, so every Resource/site/host/service/oauth
|
||||
// row (the whole Directory Management page) would be silently wiped on
|
||||
// every container recreate. /data is already a persisted volume (Redis
|
||||
// lives there too), so this just co-locates the sqlite file with it.
|
||||
orm: {
|
||||
dialect: 'sqlite',
|
||||
storage: '/data/inventory.sqlite',
|
||||
},
|
||||
|
||||
// ── Orchestrator-only (ignored by the app; read by setup.sh + bootstrap) ──
|
||||
stack: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# theta-env — unified SSO Manager + Proxy.
|
||||
# theta-suite — unified SSO Manager + Proxy.
|
||||
#
|
||||
# Brings up the two all-in-one images on one bridge network so the proxy can
|
||||
# reach the SSO internally (http://sso-manager:3001 for token/userinfo,
|
||||
@@ -36,25 +36,66 @@ services:
|
||||
# setup.sh sets this from the host, where the submodule resolves
|
||||
# correctly (git -C sso-manager-node rev-parse --short HEAD).
|
||||
GIT_COMMIT: ${SSO_GIT_COMMIT:-}
|
||||
# Optional upstream HTTP(S) proxy for npm/apt during the build (NOT
|
||||
# the theta42 "proxy" app). Set CFG_HTTP_PROXY in setup.env; empty by
|
||||
# default, so this is a no-op unless configured.
|
||||
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
|
||||
NO_PROXY: ${CFG_NO_PROXY:-}
|
||||
container_name: sso-manager
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- openbao
|
||||
networks: [theta-net]
|
||||
ports:
|
||||
# SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so
|
||||
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
|
||||
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
|
||||
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
|
||||
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
|
||||
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
|
||||
- "${LDAPS_PORT:-636}:636"
|
||||
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
|
||||
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
|
||||
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
|
||||
# against ldap://localhost and ldaps://localhost, and the LDAP server is
|
||||
# co-located on this host, so BOTH ports must be reachable from the host
|
||||
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
|
||||
# so LAN clients can use the host's local IP too; set LDAP_BIND and/or
|
||||
# LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal
|
||||
# hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet.
|
||||
- "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389"
|
||||
- "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636"
|
||||
environment:
|
||||
# Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see
|
||||
# volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app
|
||||
# reads that are not part of its conf tree.
|
||||
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
|
||||
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
|
||||
# deep-merges secret/sso-manager/conf from OpenBao over it at boot
|
||||
# (VAULT_ADDR/VAULT_TOKEN below). NODE_ENV/NODE_PORT are the only other
|
||||
# env the app reads. VAULT_TOKEN is the scoped SSO_VAULT_TOKEN minted by
|
||||
# setup.sh (policy sso-broker) — NOT the root token.
|
||||
- NODE_ENV=production
|
||||
- NODE_PORT=3001
|
||||
# Only a first-run default (site_config.js's envDefaults()) -- a real
|
||||
# join/promote persists its own value to /config/site.json afterward,
|
||||
# which always wins. Derived by setup.sh from CFG_SITE_NAME.
|
||||
- SITE_SLUG=${SITE_SLUG:-}
|
||||
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
|
||||
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
|
||||
# utils/proxy_client.js (no-inbound relay automation) and
|
||||
# utils/jump_client.js (real mesh-gateway count on the Multi-Site
|
||||
# modal) both no-op/skip without these -- neither was ever actually
|
||||
# wired into the compose environment before, so both features were
|
||||
# unreachable in every real deployment despite existing in code.
|
||||
- PROXY_INTERNAL_URL=http://proxy:3000
|
||||
- JUMP_INTERNAL_URL=http://jump-host:3002
|
||||
- VAULT_ADDR=http://openbao:8200
|
||||
- VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
|
||||
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
|
||||
# runtime. See the build args above for the same setting during build.
|
||||
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
|
||||
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
|
||||
- NO_PROXY=${CFG_NO_PROXY:-}
|
||||
volumes:
|
||||
# The host docker socket so the bundled Docker discovery plugin (seeded as
|
||||
# 'docker-local' with socketPath /var/run/docker.sock) can list containers.
|
||||
# Without this the plugin errors with ENOENT and shows 'Last run: error'.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
|
||||
# can write the generated OAuth client creds into proxy-secrets.js. The
|
||||
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
|
||||
@@ -87,12 +128,19 @@ services:
|
||||
# setup.sh sets this from the host, where the submodule resolves
|
||||
# correctly (git -C proxy rev-parse --short HEAD).
|
||||
GIT_COMMIT: ${PROXY_GIT_COMMIT:-}
|
||||
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
|
||||
# the sso-manager service above for details.
|
||||
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
|
||||
NO_PROXY: ${CFG_NO_PROXY:-}
|
||||
container_name: proxy
|
||||
restart: unless-stopped
|
||||
networks: [theta-net]
|
||||
depends_on:
|
||||
sso-manager:
|
||||
condition: service_healthy
|
||||
openbao:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
@@ -102,14 +150,28 @@ services:
|
||||
# to lock it to localhost once the proxy fronts it under TLS.
|
||||
- "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000"
|
||||
environment:
|
||||
# oidc/ldap/auth config comes from ./config/proxy-secrets.js (see volumes),
|
||||
# not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
|
||||
# oidc/ldap/auth config is loaded by @simpleworkjs/conf from
|
||||
# ./config/proxy-secrets.js (see volumes), then @simpleworkjs/bao-conf
|
||||
# deep-merges secret/proxy/conf from OpenBao over it at boot. The OAuth
|
||||
# clientSecret is consumed at require time, so bao-conf.init() runs
|
||||
# BEFORE require('../app') in bin/www. NODE_ENV/NODE_PORT are process env
|
||||
# the app reads directly. VAULT_TOKEN is the scoped PROXY_VAULT_TOKEN
|
||||
# (policy proxy — read only secret/proxy/conf).
|
||||
- NODE_ENV=production
|
||||
- NODE_PORT=3000
|
||||
- VAULT_ADDR=http://openbao:8200
|
||||
- VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
|
||||
# Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's
|
||||
# Encrypt, DNS providers) at runtime.
|
||||
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
|
||||
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
|
||||
- NO_PROXY=${CFG_NO_PROXY:-}
|
||||
volumes:
|
||||
# Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy
|
||||
# only reads it; the sso-manager bootstrap writes the OAuth creds. The
|
||||
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js.
|
||||
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js. Kept as a
|
||||
# fail-soft fallback: bao-conf.init() is fail-soft, so if OpenBao is
|
||||
# unreachable the app boots from this file instead.
|
||||
- ./config:/config:ro
|
||||
# Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local
|
||||
# users, AND the auto-ssl Let's Encrypt certs survive container recreation.
|
||||
@@ -127,6 +189,126 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# SSH jump host — a core component, always built + started alongside the
|
||||
# SSO and proxy. Authenticates users against the SSO's OpenLDAP, resolves
|
||||
# reachable hosts from the directory API, and bridges SSH through.
|
||||
jump-host:
|
||||
build:
|
||||
context: ./jump-host
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
GIT_COMMIT: ${JUMP_GIT_COMMIT:-}
|
||||
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
|
||||
# the sso-manager service above for details.
|
||||
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
|
||||
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
|
||||
NO_PROXY: ${CFG_NO_PROXY:-}
|
||||
container_name: jump-host
|
||||
restart: unless-stopped
|
||||
networks: [theta-net]
|
||||
depends_on:
|
||||
sso-manager:
|
||||
condition: service_healthy
|
||||
openbao:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${JUMP_SSH_PORT:-2222}:2222" # SSH front door
|
||||
- "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# Secrets are loaded by @simpleworkjs/conf from ./config/jump-secrets.js,
|
||||
# then @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from
|
||||
# OpenBao over it at boot. VAULT_TOKEN is the scoped JUMP_VAULT_TOKEN
|
||||
# (policy jump-host — read only secret/jump-host/conf).
|
||||
- VAULT_ADDR=http://openbao:8200
|
||||
- VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
|
||||
# Optional upstream HTTP(S) proxy for outbound calls (the directory API
|
||||
# client) at runtime.
|
||||
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
|
||||
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
|
||||
- NO_PROXY=${CFG_NO_PROXY:-}
|
||||
volumes:
|
||||
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
|
||||
- jump-data:/var/lib/jump-host # generated host keys persist here
|
||||
- jump-redis-data:/data # Redis (sessions, OAuth state, API tokens) persists here
|
||||
|
||||
# A real, LDAP-joined (SSSD + AuthorizedKeysCommand) downstream host for
|
||||
# testing jump-host's actual key-injection -> upstream-connect flow --
|
||||
# a container with a manually-dropped public key in authorized_keys never
|
||||
# exercises the LDAP-key-serving path a real production host does. Built
|
||||
# from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars
|
||||
# for setup notes. Opt-in test fixture: bring it up explicitly with
|
||||
# `docker compose --profile ldap-test up` (jump-host itself now starts
|
||||
# unconditionally, so this only adds a downstream host for it to reach).
|
||||
ldap-test-host:
|
||||
profiles: ["ldap-test"]
|
||||
build:
|
||||
context: ./ldap-client
|
||||
dockerfile: Dockerfile
|
||||
container_name: ldap-test-host
|
||||
hostname: ldap-test-host
|
||||
restart: unless-stopped
|
||||
networks: [theta-net]
|
||||
depends_on:
|
||||
sso-manager:
|
||||
condition: service_healthy
|
||||
privileged: false
|
||||
volumes:
|
||||
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
|
||||
- ./config/ldap-ca.crt:/config/ldap-ca.crt:ro
|
||||
|
||||
# Renews the three periodic service tokens (theta-svc role, 768h period)
|
||||
# every 12h. Periodic tokens live forever ONLY while something renews them —
|
||||
# this sidecar is that something, so the stack survives arbitrarily long
|
||||
# uptimes and the tokens in .env never silently expire. If a token is missing
|
||||
# or already dead it just logs and moves on (setup.sh re-mints on next run).
|
||||
bao-renewer:
|
||||
image: quay.io/openbao/openbao:latest
|
||||
container_name: bao-renewer
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- openbao
|
||||
environment:
|
||||
- BAO_ADDR=http://openbao:8200
|
||||
- SSO_VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
|
||||
- PROXY_VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
|
||||
- JUMP_VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
renew() {
|
||||
if [ -z "$$2" ]; then return 0; fi
|
||||
if BAO_TOKEN="$$2" bao token renew > /dev/null 2>&1; then
|
||||
echo "[bao-renewer] renewed $$1"
|
||||
else
|
||||
echo "[bao-renewer] FAILED to renew $$1 (expired/revoked? re-run setup.sh to re-mint)"
|
||||
fi
|
||||
}
|
||||
while true; do
|
||||
renew SSO_VAULT_TOKEN "$$SSO_VAULT_TOKEN"
|
||||
renew PROXY_VAULT_TOKEN "$$PROXY_VAULT_TOKEN"
|
||||
renew JUMP_VAULT_TOKEN "$$JUMP_VAULT_TOKEN"
|
||||
sleep 43200
|
||||
done
|
||||
networks:
|
||||
- theta-net
|
||||
|
||||
openbao:
|
||||
image: quay.io/openbao/openbao:latest
|
||||
container_name: openbao
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- IPC_LOCK
|
||||
command: server -config=/vault/config/openbao.hcl
|
||||
environment:
|
||||
- BAO_ADDR=http://127.0.0.1:8200
|
||||
ports:
|
||||
- "8080:8200"
|
||||
volumes:
|
||||
- ./config/openbao.hcl:/vault/config/openbao.hcl:ro
|
||||
- openbao-data:/vault/data
|
||||
networks:
|
||||
- theta-net
|
||||
networks:
|
||||
theta-net:
|
||||
driver: bridge
|
||||
@@ -137,4 +319,7 @@ volumes:
|
||||
sso-data:
|
||||
proxy-data:
|
||||
proxy-cache:
|
||||
proxy-logs:
|
||||
proxy-logs:
|
||||
jump-data:
|
||||
jump-redis-data:
|
||||
openbao-data:
|
||||
@@ -0,0 +1,53 @@
|
||||
# theta-agent: Local-Discovery Spec (mDNS "prefer local directory")
|
||||
|
||||
**Audience**: implementer on Windows/Mac (this was authored on Linux; Windows/Mac-specific network and hosts-file behavior needs to be built and tested there, not assumed here).
|
||||
**Repo**: `theta-agent` (Go). Signing/config mechanism referenced below: `websocket.go`, `config.go`.
|
||||
**Parent doc**: [`MULTI_SITE_SPEC.md`](./MULTI_SITE_SPEC.md) §5.3 — read that section for the "why," this doc is the "what," precisely enough to implement without re-deriving the reasoning.
|
||||
|
||||
## Problem
|
||||
|
||||
A no-inbound spoke site's public hostname (e.g. `sso-staten-island.theta42.com`) resolves, from the internet, to the master's IP, which relays over WireGuard to the spoke. A device physically on that spoke's LAN resolving the same hostname takes the same path — out to the master, back over the tunnel — even though the real service is a few feet away. This is wasted latency, not a correctness bug, but it's the kind of thing users notice.
|
||||
|
||||
## What to Build
|
||||
|
||||
### 1. Announcer (gateway/proxy side — may already be scoped elsewhere, confirm before duplicating)
|
||||
The spoke's `theta-gateway` or `theta-proxy` periodically advertises itself via mDNS on the local segment:
|
||||
- Service type: `_theta-suite._tcp.local`
|
||||
- TXT records: `site=<slug>`, `hosts=<comma-separated list of public hostnames this site fronts>`
|
||||
- Advertised address: the service's own local LAN IP
|
||||
|
||||
### 2. Listener + Override (this doc's actual scope — theta-agent)
|
||||
- New config field in `agent.yml`, e.g. `prefer_local_discovered_directory: bool` (default `false` — opt-in, not automatic, since it changes name resolution behavior on the host).
|
||||
- When `true`, the agent runs an mDNS browser for `_theta-suite._tcp.local` in the background.
|
||||
- On receiving an announcement whose `hosts` TXT list includes a hostname the agent cares about (at minimum: the hostname the agent itself is currently configured to connect to for its WS connection), the agent installs a **local override** redirecting that hostname to the discovered local IP.
|
||||
- No announcement seen (agent off-site, or flag disabled) → no override installed, normal DNS resolution applies. Nothing else about the agent's behavior changes in this case.
|
||||
- If a previously-discovered site's announcement stops being seen (TTL expiry / agent moved networks), the override must be **removed**, not left stale. Don't let a laptop that left the office keep resolving the old office hostname to a now-unreachable LAN IP.
|
||||
|
||||
### 3. Override Mechanism — Platform-Specific, Needs Real Investigation
|
||||
|
||||
This is the part that most needs Windows/Mac-native work; do not assume the Linux/Unix approach ports directly:
|
||||
|
||||
- **Windows**: hosts file lives at `%SystemRoot%\System32\drivers\etc\hosts`; writing to it requires elevation, and Windows caches DNS results independently (`ipconfig /flushdns` needed after an edit, or the change won't take effect immediately — verify whether theta-agent already runs elevated on Windows, since if it doesn't, this whole approach may need a different mechanism, e.g. a local proxy/resolver instead of hosts-file edits).
|
||||
- **macOS**: hosts file at `/etc/hosts`, also requires root; macOS's mDNSResponder/DNS caching behavior differs from Windows and Linux (`dscacheutil -flushcache; killall -HUP mDNSResponder` territory) — confirm whether an installed hosts entry is actually honored promptly, or whether the built-in mDNSResponder needs to be told directly instead of fighting it with a hosts-file edit (macOS already *has* native mDNS support baked into resolution — it may be simpler/more idiomatic there to register via the OS's own Bonjour APIs rather than hand-roll hosts-file mutation).
|
||||
- **Linux**: `/etc/hosts`, requires root, comparatively straightforward, `systemd-resolved` caching considerations may apply depending on distro.
|
||||
|
||||
Given the platform divergence, seriously consider whether a **local stub resolver** (theta-agent listens on `127.0.0.1:<port>`, answers matching hostnames from its own discovery cache, forwards everything else upstream — with the OS's DNS pointed at it only for the duration this feature is active) is actually simpler and more uniform across all three platforms than hosts-file mutation, despite the extra moving part. Recommend evaluating both before committing to an implementation; this doc intentionally doesn't prescribe one, since that call needs platform testing this environment can't do.
|
||||
|
||||
## Hard Security Rule (non-negotiable, applies regardless of mechanism chosen)
|
||||
|
||||
mDNS is **unauthenticated** on a local network — anyone on the same LAN segment can broadcast a spoofed announcement. This feature may only ever change **where** the agent connects (which IP a hostname resolves to). It must **never** change **whether** the agent trusts what answers there. Concretely:
|
||||
- TLS certificate validation and hostname verification against the redirected IP must remain fully enforced — no exceptions, no "local network so it's fine" carve-out.
|
||||
- A spoofed rogue announcement pointing a hostname at an attacker's local IP should produce a TLS handshake failure (cert won't match), not a silent connection. If your chosen mechanism has *any* code path where local-discovery bypasses or weakens cert checking, that's a bug, not an optimization — fix it before shipping.
|
||||
|
||||
## Out of Scope for This Piece
|
||||
|
||||
- The announcer side's exact library/implementation on the gateway/proxy (Linux — can be built where this spec was authored, not blocked on Windows/Mac).
|
||||
- Anything about the master-relay mechanism itself (§5.2 of the parent spec) — this doc is purely the "skip the relay when local" optimization layered on top of it.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- Flag exists, defaults off.
|
||||
- Enabling it on a machine physically on a spoke's LAN measurably routes traffic to the local spoke instead of through the master relay (verify via a network capture or the proxy's own access logs on each side, not just "it feels faster").
|
||||
- Leaving that LAN (or disabling the flag) reliably reverts to normal resolution — no stale overrides.
|
||||
- A test with a spoofed/rogue mDNS announcement (a second, non-legitimate advertiser) results in a TLS failure, not a successful connection to the impostor.
|
||||
- Behavior verified on both Windows and macOS, not just Linux.
|
||||
@@ -0,0 +1,323 @@
|
||||
---
|
||||
layout: default
|
||||
title: Group & Permission Model
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Theta42 Group & Permission Model
|
||||
|
||||
This is the canonical reference for how **groups and permissions work** across the
|
||||
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
|
||||
hosts** should read and use them. It is written to be implementable by both humans
|
||||
and LLM agents.
|
||||
|
||||
Everything below assumes LDAP is the single source of truth for identity and group
|
||||
membership. Group membership is managed in the **SSO Manager Directory**, generated
|
||||
from adopted resources — there is **no standalone "Groups" page**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Principles
|
||||
|
||||
1. **Groups are a projection of the resource graph.** Every adopted host and app
|
||||
in the Directory gets its own groups, auto-created from its identity. Group
|
||||
membership is managed on the resource's modal.
|
||||
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
|
||||
hosts; an app administers apps. They do not inherit from each other.
|
||||
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
|
||||
`admin` implies `access`. Capabilities are explicit and never implied by
|
||||
`admin`.
|
||||
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
|
||||
the site slug.
|
||||
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
|
||||
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
|
||||
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
|
||||
bloat. Only the meta groups are never imported by hosts.
|
||||
6. **The directory is the only place groups are created.** `god_admin` is the sole
|
||||
group that does not belong to a resource or site.
|
||||
|
||||
---
|
||||
|
||||
## 2. Group schema
|
||||
|
||||
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
|
||||
`<capability>` = an opaque, app-defined capability token (see §4).
|
||||
|
||||
| Group | Scope | Meaning |
|
||||
| :--- | :--- | :--- |
|
||||
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
|
||||
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
|
||||
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
|
||||
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
|
||||
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
|
||||
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
|
||||
| `S_host_<host>_access` | host | Access to host `<host>`. |
|
||||
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
|
||||
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
|
||||
| `S_apps_access` | site | Access to **all apps** at `S`. |
|
||||
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
|
||||
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
|
||||
| `S_app_<app>_access` | app | Access to app `<app>`. |
|
||||
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
|
||||
|
||||
### Meta groups (implicit membership — not POSIX, no gidNumber)
|
||||
|
||||
| Group | Scope | Meaning |
|
||||
| :--- | :--- | :--- |
|
||||
| `everyone` | global | **All authenticated users**, any site. |
|
||||
| `S_everyone` | site | **All authenticated users** at site `S`. |
|
||||
|
||||
These are resolved by the directory (any authenticated user passes), never
|
||||
enumerated as LDAP members, and cannot be used as Unix groups.
|
||||
|
||||
---
|
||||
|
||||
## 3. Naming, normalization & reserved rules
|
||||
|
||||
- The **structural delimiter is `_`**. It appears only between the fixed segments
|
||||
of a group name.
|
||||
- **The `S` site segment is the site resource's slug verbatim** (`site_local`),
|
||||
NOT re-slugified (which would corrupt the delimiter: `site_local` → `site-local`).
|
||||
- **Per-resource groups are `{S}_{kind}_{name}_{level}`.** `kind` is `host` or
|
||||
`app`; `name` is the resource's **name slug with the kind prefix stripped** — a
|
||||
host resource `host_theta-env` has name `theta-env`, so its groups are
|
||||
`site_local_host_theta-env_access` / `_admin`. A service (the group model's
|
||||
`app`, docs §11) `sso-manager` gives `site_local_app_sso-manager_access`. The
|
||||
kind segment is always present, which is what makes a resource's name
|
||||
unambiguous even if a host and a service share a name.
|
||||
- **Within a segment, normalize to lowercase** — spaces and stray `_` → `-`; strip
|
||||
other non-`[a-z0-9-]`. A host named `Web 01` and a site `Main Office` (resource
|
||||
slugs `host_web-01` and `site_main-office`) yield groups `site_main-office_host_web-01_*`.
|
||||
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
|
||||
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
|
||||
if a host were named `admin` (that host would be `S_host_admin_admin`).
|
||||
- **The last segment is the level.** If it is `admin` or `access` it is a known
|
||||
level; any other value is an **opaque capability** owned by a downstream app.
|
||||
- **Total length budget:** keep a group cn under ~120 chars; reject group
|
||||
creation that would exceed it.
|
||||
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
|
||||
generated on the host by SSSD for only the groups that host imports (see §8).
|
||||
|
||||
---
|
||||
|
||||
## 4. Levels and opaque capabilities
|
||||
|
||||
- **`admin`** — manage (create/update/delete/config) the resource.
|
||||
- **`access`** — use/read the resource.
|
||||
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
|
||||
manages membership and exposes the group to the app; **the downstream app
|
||||
defines and enforces what the capability means** (e.g. `emby_admin`,
|
||||
`gitea_maintain`, `reboot`, `backup`).
|
||||
|
||||
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
|
||||
Everything else on a resource group is treated as an opaque capability group and
|
||||
passed through to consumers.
|
||||
|
||||
---
|
||||
|
||||
## 5. Permission resolution (inheritance)
|
||||
|
||||
Define a user's **effective permission** on a resource by checking, from most
|
||||
specific to most general, whether they are a member of any applicable group. The
|
||||
rule: a higher group implies everything below it.
|
||||
|
||||
### On host `H` at site `S`
|
||||
|
||||
| Wanted | Granted if the user is a member of **any** of |
|
||||
| :--- | :--- |
|
||||
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
|
||||
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
|
||||
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
|
||||
|
||||
### On app `A` at site `S`
|
||||
|
||||
Identical, with `app`/`apps` substituted for `host`/`hosts`.
|
||||
|
||||
### Management console (SSO / Proxy / Jump-Host)
|
||||
|
||||
Each console is registered as an **app** on its site, so console admin is:
|
||||
|
||||
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
|
||||
|
||||
### Pseudocode
|
||||
|
||||
```
|
||||
def effective(resource, level_or_cap, site):
|
||||
if user in "god_admin": return True
|
||||
if user in f"{site}_super_admin": return True
|
||||
if level_or_cap in ("admin","access"):
|
||||
agg = f"{site}_{resource.kind}s_{level_or_cap}"
|
||||
if user in agg: return True
|
||||
# resource.name is the resource's name slug (kind prefix stripped); the kind
|
||||
# is its own segment. A host `host_theta-env` has name `theta-env`, kind `host`.
|
||||
specific = f"{site}_{resource.kind}_{resource.name}_{level_or_cap}"
|
||||
if user in specific: return True
|
||||
if level_or_cap == "access": return effective(resource, "admin", site)
|
||||
if level_or_cap == "admin": return False # access does not imply admin
|
||||
return False
|
||||
```
|
||||
|
||||
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
|
||||
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Where groups live — the Directory, generated from adopted resources
|
||||
|
||||
- There is **no standalone Groups page.** Group creation/management happens on an
|
||||
**adopted resource** in the Directory.
|
||||
- When a host or app is **adopted** (promoted from Discovered Inventory to
|
||||
managed), the directory auto-creates its `_admin` and `_access` groups (and
|
||||
site aggregates if configured). Capability groups are created on demand.
|
||||
- Membership (add/remove users) and capability grants are managed on that
|
||||
resource's modal.
|
||||
- Deleting a resource removes its per-resource groups.
|
||||
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
|
||||
global `god_admin`/`everyone` are managed at the site level (not on a single
|
||||
host/app resource).
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-site isolation
|
||||
|
||||
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
|
||||
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
|
||||
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
|
||||
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
|
||||
`everyone` cross site boundaries.
|
||||
|
||||
---
|
||||
|
||||
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
|
||||
|
||||
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
|
||||
There are hundreds of them and only a handful matter on any given host, so we do
|
||||
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
|
||||
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
|
||||
This keeps the directory clean and the per-host surface tiny.
|
||||
|
||||
### SSSD — generate GIDs on the fly, import only what you need
|
||||
|
||||
```ini
|
||||
[domain/example]
|
||||
id_provider = ldap
|
||||
auth_provider = ldap
|
||||
ldap_uri = ldaps://ldap.example
|
||||
ldap_search_base = dc=example,dc=com
|
||||
|
||||
# groupOfNames (RFC 2307bis) schema
|
||||
ldap_schema = rfc2307bis
|
||||
ldap_group_object_class = groupOfNames
|
||||
ldap_group_member = member
|
||||
|
||||
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
|
||||
ldap_id_mapping = true
|
||||
ldap_group_uuid = entryUUID
|
||||
|
||||
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
|
||||
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
|
||||
```
|
||||
|
||||
Key ideas:
|
||||
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
|
||||
stable GID for any group it imports, so **no `gidNumber` attribute is required**
|
||||
in LDAP.
|
||||
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
|
||||
match, discarding the other hundreds. After changing the filter, clear the
|
||||
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
|
||||
`getent group <cn>`.
|
||||
|
||||
### What filter to use — the naming convention is the answer
|
||||
|
||||
A host should import its **own** resource groups (plus any explicitly granted
|
||||
ones). Because the schema is predictable, `ldap-client` can generate the per-host
|
||||
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
|
||||
at site `main-office` (site resource slug `site_main-office`) imports:
|
||||
|
||||
```
|
||||
(&(objectClass=groupOfNames)(|(cn=site_main-office_host_web01_access)
|
||||
(cn=site_main-office_host_web01_admin)
|
||||
(cn=site_main-office_host_web01_sudo)))
|
||||
```
|
||||
|
||||
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
|
||||
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
|
||||
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
|
||||
|
||||
### Aliasing an LDAP group into a local group (e.g. `input`)
|
||||
|
||||
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
|
||||
Two host-side mechanisms cover it:
|
||||
|
||||
- **pam_exec** — a script in the login stack adds the user to the local group for
|
||||
the session:
|
||||
```sh
|
||||
#!/bin/bash
|
||||
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
|
||||
```
|
||||
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
|
||||
`/etc/pam.d/common-session`.
|
||||
|
||||
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
|
||||
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
|
||||
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
|
||||
members regardless of the local GID.
|
||||
|
||||
### Meta groups
|
||||
|
||||
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
|
||||
implicit membership and are resolved by the directory only.
|
||||
|
||||
---
|
||||
|
||||
## 9. Downstream-app consumption guide
|
||||
|
||||
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
|
||||
membership from LDAP and interprets it as follows:
|
||||
|
||||
1. **Discover the user's groups** — bind with the user's credentials (or use a
|
||||
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
|
||||
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
|
||||
the `memberOf` reverse attribute on the user's entry.
|
||||
2. **Match each group to a scope:**
|
||||
- `god_admin` → the user is a global administrator.
|
||||
- `{site}_super_admin` → site administrator for that site.
|
||||
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
|
||||
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
|
||||
- `everyone` / `{site}_everyone` → the user is implicitly a member.
|
||||
3. **Interpret the last segment:**
|
||||
- `admin` → full control of that resource.
|
||||
- `access` → read/use.
|
||||
- anything else → a capability **you** define; act on it or ignore it.
|
||||
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
|
||||
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
|
||||
`{site}_app_emby_emby_admin` administers Emby.
|
||||
|
||||
The app must **never** treat an unknown last segment as `admin` or `access`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration from the legacy `app_*` groups
|
||||
|
||||
The current global groups (`app_sso_admin`, `app_super_admin`,
|
||||
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
|
||||
|
||||
| Legacy | New |
|
||||
| :--- | :--- |
|
||||
| `app_super_admin` | `god_admin` |
|
||||
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
|
||||
| `app_sso_directory_admin` | `S_app_sso_admin` |
|
||||
| `app_jump_admin` | `S_app_jump_admin` |
|
||||
|
||||
During the transition the legacy groups may be kept as short-lived aliases that
|
||||
resolve to the same effective permission; once everything is moved, remove them.
|
||||
|
||||
---
|
||||
|
||||
## 11. The management consoles are apps
|
||||
|
||||
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
|
||||
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
|
||||
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
|
||||
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
|
||||
self-consistent: the SSO is "just another app."
|
||||
@@ -0,0 +1,278 @@
|
||||
# Theta Suite Multi-Site Architecture & VPN Specification
|
||||
|
||||
**Specification Version**: `2.2.0`
|
||||
**Status**: Mostly shipped. Read the status table at the bottom before trusting any section's detail as current behavior — this document accumulated across several build passes and earlier sections describe things that were aspirational when written and real by the time later sections were added.
|
||||
**Target Suite Version**: `v1.50.0+`
|
||||
**Repository**: [`theta-suite`](https://github.com/theta42/theta-suite)
|
||||
|
||||
> ## Shipped today
|
||||
> - **Join, live replication, promotion** (`sso-manager-node`): a spoke joins via a one-time export over a site join key (`POST /api/site/join-keys` / `/export` / `/join`), then registers its own endpoint so the master can push live resync pings on every catalog write — no longer a one-time snapshot. Promotion (`POST /api/directory-admin/site-promote`) coordinates a real handoff, demoting the old master as one action. Identical agent-signing keys ride the same export/resync path. Read [`sso-manager-node/docs/site-join.md`](https://github.com/theta42/theta-directory/blob/master/docs/site-join.md) and `directory_spec.md` §11 for the endpoint-level detail.
|
||||
> - **Gateway-to-gateway WireGuard mesh** (`theta-gateway`): real site-to-site tunnels via `POST /api/mesh/register`/`/join`, kernel WireGuard with a userspace `wireguard-go` fallback. Verified with an actual two-container encrypted tunnel passing traffic, not a mock.
|
||||
> - **Cross-component routing + no-inbound relay automation**: `sso-manager-node`'s replication traffic now prefers a spoke's mesh IP over the open internet when one is on file (`utils/site_replicate.js`), and a no-inbound spoke's join (`POST /api/site/join` → `/api/site/spokes`) can carry `noInbound`/`meshIp`/`publicHost`, which drives `utils/proxy_client.js` to auto-create the relay route on the master's `theta-proxy` via its existing self-service API token system (reused, not a new credential type). The one piece that stays a manual, out-of-band step is the mesh peering itself (mint a join token on one jump-host, paste it into the other's "Join a mesh" UI) — `theta-suite`'s `bootstrap/site-relay-register.js` (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`) picks up from there on the next `setup.sh` run.
|
||||
> - **mDNS local-discovery (Linux + Windows)**: shipped and verified — `theta-gateway` announces (`services/mdns_announce.js`), `theta-agent` discovers and applies a hosts-file override, cleanly reverts when the announcement disappears. Linux was verified end-to-end over real multicast; Windows shipped in `theta-agent` v2.2.0 (CRLF-aware hosts override, `ipconfig /flushdns`, and a /32 host-route pin so the WireGuard tunnel can't swallow the direct LAN path). macOS still needs real testing — see the TODO note.
|
||||
|
||||
Design scale: a handful of sites (dozen max, 254 hard ceiling — see §4), a few hundred users/hosts total. This is a deliberate, small, trusted-operator deployment, not a hyperscale/adversarial-tenant one — several decisions below (fire-and-forget replication, identical directories) trade blast-radius for simplicity *because* the scale allows it. Don't generalize these choices past that scale without re-deriving them.
|
||||
|
||||
---
|
||||
|
||||
## 1. High-Level System Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ControlPlane["Master Site (write authority)"]
|
||||
ssoM["sso-manager-node (isMaster=true)"]
|
||||
ldapM["OpenLDAP (MMR write node)"]
|
||||
baoM["OpenBao (local, replication source)"]
|
||||
proxyM["theta-proxy"]
|
||||
gateM["theta-gateway"]
|
||||
agentM["theta-agent"]
|
||||
end
|
||||
|
||||
subgraph SiteB["Spoke — inbound (has a public IP)"]
|
||||
ssoB["sso-manager-node (isMaster=false)"]
|
||||
ldapB["OpenLDAP (MMR read replica)"]
|
||||
baoB["OpenBao (local replica)"]
|
||||
proxyB["theta-proxy — serves this site's public traffic directly"]
|
||||
gateB["theta-gateway"]
|
||||
agentB["theta-agent"]
|
||||
end
|
||||
|
||||
subgraph SiteC["Spoke — no inbound (CGNAT)"]
|
||||
ssoC["sso-manager-node (isMaster=false)"]
|
||||
ldapC["OpenLDAP (MMR read replica)"]
|
||||
baoC["OpenBao (local replica)"]
|
||||
proxyC["theta-proxy — LAN-local traffic only"]
|
||||
gateC["theta-gateway"]
|
||||
agentC["theta-agent"]
|
||||
end
|
||||
|
||||
gateM <==>|"WireGuard mesh tunnel"| gateB
|
||||
gateM <==>|"WireGuard mesh tunnel"| gateC
|
||||
|
||||
ssoM -.->|"fire-and-forget push: catalog + secrets + signing key"| ssoB
|
||||
ssoM -.->|"fire-and-forget push"| ssoC
|
||||
ldapM <==>|"OpenLDAP MMR syncrepl"| ldapB
|
||||
ldapM <==>|"OpenLDAP MMR syncrepl"| ldapC
|
||||
|
||||
proxyM -->|"TLS-terminate + relay (no direct path exists)"| gateM
|
||||
gateM ==>|"WG tunnel"| gateC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Every Directory Is Identical
|
||||
|
||||
Master and every spoke run the **same LDAP data, the same OpenBao secrets, and the same agent-signing key**. Hitting any site's `sso-manager-node` for read/auth purposes is equivalent to hitting any other. The only asymmetry is **write authority** (§3).
|
||||
|
||||
This is a deliberate tradeoff, not a default: it means compromising *any single spoke* — including the smallest, least-secured one — grants an attacker the same agent-command authority (`update_binary`, `arbitrary_bash`, service control) as compromising the master, because every site holds the same Ed25519 signing key (`sso-manager-node/nodejs/utils/agent_keys.js`). Accepted here because the deployment scale is small and trusted. Do not extend this pattern to a larger/adversarial-tenant deployment without revisiting it.
|
||||
|
||||
Consequence: `theta-agent` needs **no change** to support multi-site — it already does TOFU pairing against a single trusted key (`websocket.go:341-351`), and since that key is identical everywhere, any site's `sso-manager-node` can validly sign a command for any agent, anywhere, without agents needing a keyring.
|
||||
|
||||
### 2.1 What Replicates, and How
|
||||
|
||||
| Data | Mechanism | Direction |
|
||||
|---|---|---|
|
||||
| LDAP (users, groups) | OpenLDAP MMR syncrepl | master (write) → spokes (read-only) |
|
||||
| OpenBao secrets (incl. agent-signing key at `secret/agent/signing-key`) | **New**: custom replicator (OpenBao has no built-in multi-site replication — Performance Replication is Vault-Enterprise-only, confirmed absent from OpenBao as of this writing) | master (write) → spokes (read-only) |
|
||||
| Directory catalog (Resources: hosts, apps, sites) | Existing catalog change events | master (write) → spokes (read-only) |
|
||||
| Audit log | Async batch worker, already speced (§6) | spokes → master |
|
||||
|
||||
### 2.2 Replication Delivery: Fire-and-Forget
|
||||
|
||||
Master is the sole writer (§3), so there is exactly one producer per data type — no conflict resolution, no consensus, no vector clocks needed. On every write, master pushes the change to all connected spokes **concurrently** (not sequentially — spokes are independent WG peers, none blocks on another) and does **not** wait for acks. A spoke that's offline queues nothing on the master's side; on reconnect, the spoke pulls (or master replays) missed versions.
|
||||
|
||||
This is a deliberate choice over "wait for all spokes to ack": with a dozen spokes, concurrent push completes in low hundreds of milliseconds on the happy path, but *waiting* for acks makes every write's latency bounded by the slowest/offline spoke — reintroducing the split-brain-adjacent stall that §3's explicit-promotion design exists to avoid. Never make a master write block on spoke reachability.
|
||||
|
||||
---
|
||||
|
||||
## 3. Explicit Master Control & Human `god_admin` Authority
|
||||
|
||||
Automatic failover across WAN is explicitly disabled — 0% split-brain risk by design:
|
||||
|
||||
```
|
||||
WAN OUTAGE DETECTED
|
||||
│
|
||||
▼
|
||||
Spoke Node Unconditionally Retains SPOKE Mode
|
||||
│
|
||||
▼
|
||||
Requires Human god_admin Promotion Action
|
||||
```
|
||||
|
||||
1. **Unreachable master**: a spoke that loses the master unconditionally stays a spoke. No auto-election.
|
||||
2. **Promotion is a single coordinated action, not two steps**: `POST /api/directory-admin/site-promote` (god_admin-gated) calls out to the *current* master over the WG tunnel and demotes it as part of the same operation — there's never a window with two masters. (Requires the old master to be reachable; if it isn't, that's an operator-visible failure to resolve manually, not a silent partial-promotion.)
|
||||
3. Because every directory is identical (§2), promotion carries **no agent re-keying cost** — this was the main risk in earlier drafts of this design and is now moot.
|
||||
4. Site state (name, slug, `isMaster`, `masterUrl`, `wanConnected`) lives on the site's own `kind:'site'` Resource (`metadata.multiSite`), not in server memory — it must survive restarts and be visible via the same directory API as everything else.
|
||||
|
||||
---
|
||||
|
||||
## 4. `spoke.env` vs `setup.env`
|
||||
|
||||
A spoke shares almost none of `setup.env`'s concerns (it doesn't mint LDAP admin/JWT/service-account secrets — those arrive via replication, §2) so it gets its own, much shorter file:
|
||||
|
||||
```
|
||||
CFG_DOMAIN=theta42.com # REQUIRED, must match the master's exactly — this is the shared LDAP base DN (dc=theta42,dc=com). Never per-site.
|
||||
CFG_SITE_NAME=staten-island # this site's name/slug
|
||||
CFG_SPOKE_INBOUND=false # true: this site has a public IP and serves its own traffic directly (standalone-style). false: no inbound path exists; master relays (§5).
|
||||
CFG_PUBLIC_DOMAIN= # only used when CFG_SPOKE_INBOUND=true — this site's own domain, own DNS, own ACME cert, independent of the master's domain.
|
||||
CFG_JOIN_TOKEN= # one-time token from the master, used for WG mesh auto-registration (§4.1) and initial catalog/secret pull.
|
||||
CFG_MASTER_ENDPOINT= # master's WG endpoint (host:port) to join through.
|
||||
```
|
||||
|
||||
`CFG_DOMAIN` is the identity namespace (LDAP DN) and must be identical across every site — MMR replicas cannot diverge on base DN. `CFG_PUBLIC_DOMAIN` is a *web-hostname* concern, unrelated to LDAP, and only exists at all for inbound spokes.
|
||||
|
||||
### 4.1 WireGuard Mesh Auto-Registration
|
||||
|
||||
1. A new `theta-gateway` boots with `CFG_JOIN_TOKEN` + `CFG_MASTER_ENDPOINT`, generates its Curve25519 keypair, and calls `POST /api/mesh/gateway/register` on the master over an initial bootstrap tunnel.
|
||||
2. Master assigns the next free **site index** (one octet, used identically in both `172.24.<site>.0/16` and `10.<site>.0.0/16` per the reference topology in Appendix A) and returns full mesh peer config.
|
||||
3. **Site index ceiling is 254** (0 and 255 excluded) — a hard technical limit of this addressing scheme, not an arbitrary cap. Real deployments target a dozen or fewer; no need to cap lower than the real ceiling.
|
||||
4. Each `theta-gateway` applies the new peer set to its running `wg0` via `wgctrl` without dropping existing connections.
|
||||
|
||||
---
|
||||
|
||||
## 5. Inbound vs. No-Inbound Spokes
|
||||
|
||||
Whether a spoke has a public IP determines everything about how its traffic reaches the outside world — these are two distinct, documented operating modes, not a single universal mechanism.
|
||||
|
||||
### 5.1 Inbound Spoke (`CFG_SPOKE_INBOUND=true`)
|
||||
Behaves like a standalone install. Own `CFG_PUBLIC_DOMAIN`, own DNS pointed at its own public IP, own ACME cert. `theta-proxy` and `theta-gateway` serve public web + SSH traffic directly — no relay involved. The only WAN-facing traffic to the master is replication (§2) and audit shipping (§6).
|
||||
|
||||
### 5.2 No-Inbound Spoke (`CFG_SPOKE_INBOUND=false`)
|
||||
No public IP exists, so *any* external access must go through the master:
|
||||
|
||||
1. Master mints a public hostname for the spoke's services (e.g. `sso-{slug}.{master's public domain}`) and creates the corresponding `theta-proxy` route (already dynamic/DB-backed — `proxy/nodejs/models/host.js` — no new plumbing needed there).
|
||||
2. Master **terminates TLS** for that hostname and relays to the spoke over the WG tunnel — both `theta-proxy` (any site-hosted web app) and `theta-gateway` (SSH jump) traffic relay this way, not just SSO.
|
||||
3. Terminating at the master (rather than SNI passthrough) is fine here specifically because master↔spoke already rides an encrypted WG tunnel — there's no unencrypted hop being introduced.
|
||||
|
||||
### 5.3 Local-Direct Resolution (Skip the Relay On-LAN)
|
||||
|
||||
A client physically on a no-inbound spoke's LAN would otherwise hairpin out to the master and back to reach its own local site. Solved via **mDNS local-service-discovery**, not directory-side network topology:
|
||||
|
||||
1. The spoke's `theta-gateway`/`theta-proxy` announces itself on the local segment via mDNS (`_theta-suite._tcp.local`, TXT records: site slug, public hostnames it fronts, local IP).
|
||||
2. `theta-agent`, when a config flag (`preferLocalDiscoveredDirectory` or similar — see the agent-side spec, Appendix B) is enabled, listens for this announcement and overrides local resolution for matching hostnames to the discovered local IP.
|
||||
3. No match (off-site, or flag disabled) → normal public DNS → master relay. Multicast is link-local by nature, so "on-site or not" needs no explicit detection logic — presence/absence of the announcement *is* the signal. This also solves roaming-admin access (§ formerly "5", folded in here) for free: same laptop, same flag, local-fast-path at the office and relay-path everywhere else.
|
||||
4. **Hard rule**: mDNS is unauthenticated on a LAN. It may only ever change *where* the agent connects, never *whether* it trusts what answers — TLS/hostname validation against the redirected IP must stay intact, so a spoofed rogue announcement produces a TLS failure, not a silent MITM.
|
||||
|
||||
This piece needs Windows/Mac-specific implementation and testing that can't be done from this (Linux) environment — see Appendix B for the standalone spec handed off for that work.
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Canonical Audit Logging
|
||||
|
||||
Unchanged from prior draft: OAuth logins, SSH session events, proxy access, and agent execution events write to local site audit tables without blocking on WAN. An async worker flushes batches to master via `POST /api/directory-admin/audit/ingest` when reachable.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Production Reference WireGuard Topology Config
|
||||
|
||||
### Site 10.2 (Staten Island LAN Node) Gateway Reference (`wg0.conf`)
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 172.24.0.2/32
|
||||
PrivateKey = <SITE_10_2_PRIVATE_KEY>
|
||||
ListenPort = 51820
|
||||
Table = off
|
||||
|
||||
# Mesh Subnet Routes
|
||||
PostUp = ip route add 10.0.0.0/8 dev %i
|
||||
PostUp = ip route add 172.24.0.0/13 dev %i
|
||||
|
||||
# Policy Routing Exits
|
||||
PostUp = ip route add default via 10.5.0.1 dev %i table offshore
|
||||
PostUp = ip route add default via 172.24.0.1 dev %i table us_vps
|
||||
PostUp = ip rule add from 10.2.254.0/24 lookup offshore
|
||||
PostUp = ip rule add from 10.2.253.0/24 lookup main preference 1000
|
||||
|
||||
# NETMAP Shadow Network (10.2.168.x -> 192.168.1.x)
|
||||
PostUp = iptables -t nat -A PREROUTING -i %i -d 10.2.168.0/24 -j NETMAP --to 192.168.1.0/24
|
||||
PostUp = iptables -t nat -A POSTROUTING -o %i -s 192.168.1.0/24 -j NETMAP --to 10.2.168.0/24
|
||||
PostUp = ip route add local 10.2.168.0/24 dev lo
|
||||
|
||||
# Forwarding & NAT
|
||||
PostUp = iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o %i -j MASQUERADE
|
||||
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
PostUp = iptables -A FORWARD -i %i -o eth0 -j ACCEPT
|
||||
PostUp = iptables -A FORWARD -i eth0 -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||
|
||||
# System Kernel Options
|
||||
PostUp = sysctl -w net.ipv4.ip_forward=1
|
||||
PostUp = sysctl -w net.ipv4.conf.all.rp_filter=0
|
||||
PostUp = sysctl -w net.ipv4.conf.eth0.rp_filter=0
|
||||
PostUp = sysctl -w net.ipv4.conf.%i.rp_filter=0
|
||||
|
||||
# --- PEERS ---
|
||||
[Peer]
|
||||
# Site 10.1: US Hub / VPS Exit
|
||||
PublicKey = QZCvR3N1CdUabC2xWfc1lmYKHfSiXYs1UoVINIMftws=
|
||||
Endpoint = gg-si1.wgnode.com:51820
|
||||
AllowedIPs = 172.24.0.0/16, 10.0.0.0/8, 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
|
||||
[Peer]
|
||||
# Site 10.5: Netherlands Offshore Exit Node
|
||||
PublicKey = MlF6h3YI1MIvOlgyNozCMoa/rICoLNtc7r/pseKiHQQ=
|
||||
Endpoint = nl-alexhost.wgnode.com:51871
|
||||
AllowedIPs = 172.24.0.5/32, 10.5.0.0/16, 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### Site 10.5 (Netherlands Exit Node) Gateway Reference (`wg0.conf`)
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 172.24.0.5/32
|
||||
PrivateKey = <SITE_10_5_PRIVATE_KEY>
|
||||
ListenPort = 51871
|
||||
|
||||
PostUp = ip addr add 10.5.0.1/16 dev %i
|
||||
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
# Dynamic Return Path Masquerading (SOURCENAT)
|
||||
PostUp = iptables -t nat -A POSTROUTING -o %i ! -s 172.24.0.0/13 -j MASQUERADE
|
||||
PostUp = sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
[Peer]
|
||||
# Site 10.2: Staten Island LAN
|
||||
PublicKey = AsS7aikCUrXpdfSvwFnMs0yUaoQ7ZCkoUVOmNdl7NS8=
|
||||
AllowedIPs = 172.24.0.2/32, 10.2.0.0/16
|
||||
|
||||
[Peer]
|
||||
# Site 10.1: US Hub VPS
|
||||
PublicKey = QZCvR3N1CdUabC2xWfc1lmYKHfSiXYs1UoVINIMftws=
|
||||
AllowedIPs = 172.24.0.1/32, 10.1.0.0/8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Agent-Side Work
|
||||
|
||||
See [`AGENT_LOCAL_DISCOVERY_SPEC.md`](./AGENT_LOCAL_DISCOVERY_SPEC.md) — split out because it needs Windows/Mac implementation and testing that a Linux-only dev environment cannot meaningfully do. That doc is the handoff: it specifies behavior precisely enough to implement and test independently, without needing to re-derive the reasoning in this file.
|
||||
|
||||
---
|
||||
|
||||
## Status of This Spec vs. Code (as of this revision)
|
||||
|
||||
| Piece | Status |
|
||||
|---|---|
|
||||
| Site role persisted (not in-memory) | **Shipped** — `/config/site.json` on `sso-manager-node`, survives restarts (v2.2.0) |
|
||||
| Join key issuance + one-time directory adoption | **Shipped** — `/api/site/join-keys`, `/api/site/export`, `/api/site/join`, fresh-install-gated (v2.2.0–v2.3.0) |
|
||||
| Spoke read-only enforcement | **Shipped** — directory-write routes 403 toward the master once joined (v2.3.0) |
|
||||
| WAN health check | **Shipped** — `/api/site/ping`, live in the Master Site modal (v2.2.0–v2.3.0) |
|
||||
| `setup.env` / `setup.sh` join wiring | **Shipped** — `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY`, `bootstrap/site-join.js` (theta-suite v2.2.0). Also readable from a dedicated `spoke.env` (`spoke.env.example`, layered on top of `setup.env`) for operators who want join-a-cluster config kept separate from the rest of first-run setup. |
|
||||
| Continuous/live replication (vs. one-time export-on-join) | **Shipped** (`sso-manager-node`) — a spoke registers its own endpoint at join time (`POST /api/site/spokes`), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, which re-pulls a fresh export. Verified end-to-end in `docker-compose.multisite-e2e.yml`. |
|
||||
| Identical-directory signing key | **Shipped** — `POST /api/site/export` includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on join and every resync. OpenBao secret replication *beyond* this one key is still not built. |
|
||||
| OpenLDAP N-way multi-master replication auto-config | **Shipped** — the master auto-assigns each spoke a unique `LDAP_SERVER_ID` at registration (`SiteSpoke.ldapServerId`, same pattern as jump-host's mesh index) and derives every site's LDAP URL from its already-known HTTPS endpoint; `theta-suite`'s `bootstrap/site-ldap-register.js` applies it, re-checked on every `setup.sh` run since the peer list grows as spokes join. Verified against real running containers. Known gap: the master's own config only updates when ITS `setup.sh` is re-run, not live the moment a new spoke joins (see `docs/replication.md`). |
|
||||
| Coordinated master promotion (demote the old master as one action) | **Shipped** — `POST /api/site/demote` + `site-promote`'s handoff logic. Fixed two real pre-existing bugs while wiring this in: `site-promote`'s god_admin check read a `req.user.groups` field nothing ever populated (permanently 403'd for everyone), and the read-only write-gate 403'd `site-promote` itself before the handler could run. |
|
||||
| WireGuard gateway-to-gateway mesh (`theta-gateway`) | **Shipped** — `POST /api/mesh/register`/`/join` (join-token bootstrap), `utils/wg_iface.js` (kernel WireGuard, falls back to userspace `wireguard-go`). Verified with a real two-container test: actual encrypted tunnel, real ICMP traffic across it, 0% loss. `wg_iface.removePeer()` also cleans up the kernel routes `setPeer()` added (verified live: routes present after `setPeer`, gone after `removePeer`, own local route untouched), and `DELETE /api/mesh/gateways/:id` exposes it from the mesh UI. |
|
||||
| Cross-component routing (replication over the mesh) | **Shipped** — `utils/site_replicate.js` tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) when pushing resync pings; a spoke with no `meshIp` on file behaves exactly as before. |
|
||||
| No-inbound-spoke relay (master proxies a spoke with no public IP) | **Shipped at the API/automation layer, wired into the real bootstrap flow.** `POST /api/site/join`/`/api/site/spokes` accept `noInbound`/`meshIp`/`publicHost` and call `utils/proxy_client.js`, which mints/reuses a `theta-proxy` self-service API token (`prx_...`, OpenBao `secret/integrations/theta-proxy`) and calls the proxy's real Host API to create or update the relay route — verified against a real running `theta-proxy` container (`GET /api/host/:item`'s actual `{item, results: {...}}` response shape, not the flat shape first assumed). `theta-suite`'s `bootstrap/site-relay-register.js` + `CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST` (`setup.env.example`) drive it from the operator-facing bring-up flow, re-run automatically on every `setup.sh` invocation until the jump-host mesh IP is discoverable. What's still a manual step, deliberately: the gateway-to-gateway mesh *peering* itself (mint a join token on one jump-host, paste it into the other's UI) — same pattern as minting/pasting a site join key, not something an unattended script should do blind. A spoke with zero inbound *and* zero outbound path still can't join at all (join/export still need the spoke to reach the master's API directly). |
|
||||
| mDNS local-discovery (Linux) | **Shipped** — `theta-gateway` announces (`services/mdns_announce.js`, opt-in via `THETA_LOCAL_DISCOVERY_HOSTS`), `theta-agent` discovers and applies a hosts-file override (`local_discovery.go`, opt-in via `prefer_local_directory`). Verified end-to-end with real containers over real multicast: announce → discover → apply → clean revert on disappearance, all confirmed. Caught two real bugs along the way (`mdns.Lookup()`'s IPv6 query aborting the whole lookup even after a valid IPv4 response arrived; `rename()` failing with EBUSY over a bind-mounted `/etc/hosts`, common in every container runtime) — see the commit messages in `theta-agent`. |
|
||||
| mDNS local-discovery (Windows) | **Shipped** — `theta-agent` v2.2.0: Windows hosts override (`%SystemRoot%\System32\drivers\etc\hosts`, CRLF-aware, `ipconfig /flushdns` after each change — reachable because the agent runs as a SYSTEM service, so the elevation question resolved in our favor), plus a /32 host-route pin via the owning local interface (`route.exe add ... metric 1`) so the WireGuard mesh tunnel can't swallow the direct LAN path, and a prompt WS reconnect on apply/revert. Tests run the real Windows write path on the Windows CI leg. |
|
||||
| mDNS local-discovery (macOS) | Not built — the hosts override compiles on darwin via the shared unix path, but macOS still needs `dscacheutil -flushcache` and real hardware testing (mDNSResponder behavior, hosts-file vs. native Bonjour — see Appendix B §3). Being built on a real macOS VM. |
|
||||
|
||||
### TODO — what's actually left
|
||||
|
||||
1. **Full secret replication** — only the agent-signing key is replicated today. LDAP admin credentials, JWT secrets, and other per-deployment secrets still differ per site, which complicates full disaster recovery. **Paused pending a real-deployment question independent of the code**: this repo's own `conf/secrets.js` was found to contain committed real credentials during this work (LDAP bind, SMTP, VoIP.ms) — see the git-remediation note elsewhere in this repo's history. Building a feature that copies live secrets to additional sites shouldn't proceed until provider-side rotation of those specific credentials is confirmed done; the mechanism itself (generic secret sync, never touching those particular values) can still be designed without that answer.
|
||||
2. Service-to-service auth, cross-component routing, no-inbound relay automation, and mesh peer cleanup (the four items formerly listed here) are **done** — see the status table above. What remains genuinely open in that area is documented there inline (mesh peering stays a manual step by design; zero-inbound-and-zero-outbound spokes still can't join).
|
||||
|
||||
**mDNS local-discovery, macOS** is deliberately not listed above: the Linux and Windows sides are shipped and verified (`theta-agent` v2.2.0), and macOS is being built on a real macOS VM where the darwin-specific behavior (mDNSResponder/DNS-cache) can actually be tested. Check `theta-agent`'s recent history before assuming it's still open.
|
||||
|
||||
*Committed under [`docs/MULTI_SITE_SPEC.md`](file:///home/william/dev/theta42/theta-env/docs/MULTI_SITE_SPEC.md).*
|
||||
@@ -1,7 +1,7 @@
|
||||
title: theta-env
|
||||
title: Theta Suite
|
||||
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses.
|
||||
url: "https://theta42.github.io"
|
||||
baseurl: "/theta-env"
|
||||
baseurl: "/theta-suite"
|
||||
logo: /assets/img/theta42.svg
|
||||
lang: en_US
|
||||
|
||||
@@ -10,10 +10,10 @@ plugins:
|
||||
- jekyll-sitemap
|
||||
|
||||
github:
|
||||
repository_url: https://github.com/theta42/theta-env
|
||||
zip_url: https://github.com/theta42/theta-env/archive/refs/heads/master.zip
|
||||
tar_url: https://github.com/theta42/theta-env/archive/refs/heads/master.tar.gz
|
||||
repository_name: theta42/theta-env
|
||||
repository_url: https://github.com/theta42/theta-suite
|
||||
zip_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.zip
|
||||
tar_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.tar.gz
|
||||
repository_name: theta42/theta-suite
|
||||
|
||||
nav:
|
||||
- title: Home
|
||||
@@ -25,11 +25,20 @@ nav:
|
||||
- title: Architecture
|
||||
page: /architecture.html
|
||||
icon: fa-sitemap
|
||||
- title: Standalone
|
||||
page: /standalone.html
|
||||
icon: fa-puzzle-piece
|
||||
- title: Secrets
|
||||
page: /secrets.html
|
||||
icon: fa-key
|
||||
- title: Theta Directory
|
||||
page: /sso/
|
||||
icon: fa-users
|
||||
- title: Theta Proxy
|
||||
page: /proxy/
|
||||
icon: fa-shield-halved
|
||||
- title: Theta Gateway
|
||||
page: /jump-host/
|
||||
icon: fa-terminal
|
||||
- title: Changelog
|
||||
url: https://github.com/theta42/theta-env/blob/master/CHANGELOG.md
|
||||
url: https://github.com/theta42/theta-suite/blob/master/CHANGELOG.md
|
||||
icon: fa-list
|
||||
|
||||
defaults:
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
|
||||
<script defer src="https://tracking.718it.biz/script.js" data-website-id="a5df0dec-6c54-4c1a-a167-02867a56e2cc"></script>
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
|
||||
@@ -1,77 +1,125 @@
|
||||
---
|
||||
layout: default
|
||||
title: Architecture
|
||||
description: How theta-env composes the SSO Manager and proxy submodules — the OIDC/LDAP wiring setup.sh generates from one domain.
|
||||
description: How Theta Suite 2.0 composes Theta Directory, Theta Gateway, Theta Proxy, and Theta Agent around a shared OpenBao secrets store — the zero-trust identity, mesh gateway, and telemetry architecture.
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
theta-env is a **composition** repo: it builds the two existing projects from
|
||||
their git submodules and adds the glue that wires them together. It does not
|
||||
fork or patch them — both projects work unchanged on their own.
|
||||
Theta Suite 2.0 is a production-grade **composition repository**: it composes applications from git submodules and provides the automated first-run orchestration, secrets initialization, and container networking for a complete zero-trust infrastructure stack.
|
||||
|
||||
---
|
||||
|
||||
## The three repos
|
||||
## Core Infrastructure Components
|
||||
|
||||
| Repo | Role |
|
||||
| Subproject / Image | Component Role |
|
||||
|------|------|
|
||||
| [`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node) | OIDC provider + OpenLDAP directory + web UI. All-in-one image (`Dockerfile.openldap`). |
|
||||
| [`theta42/proxy`](https://github.com/theta42/proxy) | OIDC-protected reverse proxy (OpenResty + Node mgmt app + Redis). All-in-one image (`Dockerfile`). |
|
||||
| `theta42/theta-env` (this repo) | Composes the two on one Docker network + automates first-run wiring. |
|
||||
|
||||
The two projects are pinned as **git submodules**. `git clone --recursive`
|
||||
fetches all three in one step; `git submodule update --remote` bumps them.
|
||||
| [`theta42/theta-directory`](https://github.com/theta42/theta-directory) | **Theta Directory** — OIDC provider + OpenLDAP directory + Resource Catalog + Web Admin Console. All-in-one container. |
|
||||
| [`theta42/jump-host`](https://github.com/theta42/jump-host) | **Theta Gateway** — Directory-driven SSH access gateway and WireGuard mesh router with NETMAP shadow subnets. |
|
||||
| [`theta42/theta-agent`](https://github.com/theta42/theta-agent) | **Theta Agent** — Multi-platform host telemetry, hardware details, desktop session controls, and secret delivery agent. |
|
||||
| [`theta42/proxy`](https://github.com/theta42/proxy) | **Theta Proxy** — OIDC-protected reverse proxy (OpenResty + Node management app + Redis). |
|
||||
| [`theta42/ldap-client`](https://github.com/theta42/ldap-client) | **ldap-client** — Enrolls real Linux hosts into the directory for PAM/SSSD login, sudo rules, and SSH keys. |
|
||||
| `quay.io/openbao/openbao` | **OpenBao** — Central secrets engine (Vault fork), KV-v2 versioned store at `secret/`. |
|
||||
| `theta42/theta-suite` (this repo) | Composes all components on a single Docker network + automates `./setup.sh` first-run wiring. |
|
||||
|
||||
---
|
||||
|
||||
## The two containers
|
||||
## Architecture Stack
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ your browser / apps / legacy LDAP clients │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ https (:443) ldaps (:636)
|
||||
┌─────────▼─────────┐
|
||||
│ proxy container │ OpenResty :80/:443/:4443
|
||||
│ (OIDC + LDAP) │ Node mgmt app :3000 (localhost only)
|
||||
│ │ bundled Redis (127.0.0.1:6379)
|
||||
└─────────┬─────────┘
|
||||
┌─────────────┼────────────────────────────┐
|
||||
│ ldaps:636 │ http:3001 (internal) │ OIDC token + userinfo
|
||||
│ (docker net)│ (docker net, not published)│ (server-to-server)
|
||||
▼ ▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ sso-manager container │◄──────────────────┘
|
||||
│ OIDC provider (Express) │ bundled Redis (127.0.0.1:6379)
|
||||
│ OpenLDAP (slapd) │ web UI :3001 (localhost only)
|
||||
│ ldaps :636 (published) │
|
||||
└───────────────────────────────┘
|
||||
▲
|
||||
│ ldaps :636 (published to host) — legacy apps bind directly
|
||||
│
|
||||
┌──────────────────────────────┐
|
||||
│ legacy apps (Gitea, Emby, …)│
|
||||
└──────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ browser / OIDC apps │ SSH clients │ Linux hosts │
|
||||
│ │ │ (PAM/SSSD, sudo, keys) │
|
||||
└────────┬────────────┴──────┬──────┴───────────┬───────────┘
|
||||
https (:443) ssh (:2222) ldaps (:636)
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌──────────▼────────┐ │
|
||||
│ theta-proxy │ │ theta-gateway │ │
|
||||
│ OpenResty │ │ SSH Gateway │ │
|
||||
│ :80/:443/:4443 │ │ WireGuard Mesh │ │
|
||||
│ mgmt app :3000 │ └────────┬──────────┘ │
|
||||
└────────┬─────────┘ │ OIDC + LDAP │
|
||||
│ http:3001 (internal)│ via theta-directory
|
||||
▼ ▼ ▼
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
│ theta-directory (Express + OpenLDAP + Redis) │
|
||||
│ OIDC provider + LDAP directory + Resource Catalog │
|
||||
│ web UI :3001 (internal) ldaps :636 (published) │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
▲ loads secrets at boot (scoped token each)
|
||||
┌───────────┴───────────────────┐
|
||||
│ openbao (KV-v2 at secret/) │ ← central secrets store
|
||||
│ :8200 (internal) │ per-user + per-app KV
|
||||
│ :8080 (operator UI/API) │
|
||||
└───────────────────────────────┘
|
||||
|
||||
ldap-client — enrolls real Linux hosts into the directory above
|
||||
(PAM/SSSD login, sudo, SSH-key serving); also the
|
||||
`ldap-test-host` fixture (opt-in: `--profile ldap-test`).
|
||||
```
|
||||
|
||||
Both containers bundle their **own Redis** (the proxy hardcodes `127.0.0.1:6379`
|
||||
in three places that ignore config; the SSO's models default to the same). Two
|
||||
redis instances is the no-source-patch path and is fine at this scale.
|
||||
All four services bundle their **own Redis** (sso-manager, proxy, jump-host
|
||||
each run a 127.0.0.1:6379 instance) and share the `openbao` secrets store.
|
||||
Direct LDAP binds against `:636` are first-class — that's how Linux hosts do
|
||||
PAM/SSSD login, sudo, and SSH-key serving, and how LDAP-native apps
|
||||
authenticate — not a fallback path.
|
||||
|
||||
### What's exposed, what's not
|
||||
|
||||
| Port | On host? | Purpose |
|
||||
|------|----------|---------|
|
||||
| `443` (proxy) | **yes** | the public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
|
||||
| `80` (proxy) | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
|
||||
| `4443` (proxy) | yes (optional) | alt HTTPS listener |
|
||||
| `3000` (proxy) | localhost only | proxy mgmt UI/API (first-run convenience; fronted by 443 normally) |
|
||||
| `636` (sso) | yes | LDAPS for legacy direct-LDAP clients |
|
||||
| `3001` (sso) | localhost only | SSO web UI (first-run convenience; fronted by the proxy normally) |
|
||||
| `389` (sso) | **no** | plain LDAP — internal only (app↔slapd over localhost) |
|
||||
| Port | Service | On host? | Purpose |
|
||||
|------|---------|----------|---------|
|
||||
| `443` | proxy | **yes** | public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
|
||||
| `80` | proxy | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
|
||||
| `4443` | proxy | yes (optional) | alt HTTPS listener |
|
||||
| `3000` | proxy | localhost/LAN | proxy mgmt UI/API (fronted by 443 normally) |
|
||||
| `3001` | sso-manager | localhost/LAN | SSO web UI (fronted by the proxy normally) |
|
||||
| `636` | sso-manager | **yes** | LDAPS for direct-LDAP clients (Linux hosts, LDAP-native apps) |
|
||||
| `389` | sso-manager | **no** | plain LDAP — internal only (app↔slapd over localhost) |
|
||||
| `2222` | jump-host | **yes** | SSH front door |
|
||||
| `3002` | jump-host | **yes** | jump-host web UI/API |
|
||||
| `8080` | openbao | yes | OpenBao UI/API for the operator (apps use `openbao:8200` internally) |
|
||||
|
||||
---
|
||||
|
||||
## Secrets (OpenBao)
|
||||
|
||||
Every component loads its secrets from one OpenBao instance at boot, not
|
||||
from scattered config files. OpenBao runs as the `openbao` container
|
||||
(`http://openbao:8200` on theta-net, KV-v2 at `secret/`); each app gets a
|
||||
**scoped token** (never the root token) whose OpenBao policy confines it to
|
||||
the paths it needs:
|
||||
|
||||
| Service | env var | Policy | Access |
|
||||
|---------|---------|--------|--------|
|
||||
| sso-manager | `SSO_VAULT_TOKEN` | `sso-broker` | `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; also mints per-user + per-app tokens |
|
||||
| proxy | `PROXY_VAULT_TOKEN` | `proxy` | `secret/proxy/conf` (read) |
|
||||
| jump-host | `JUMP_VAULT_TOKEN` | `jump-host` | `secret/jump-host/conf` (read) |
|
||||
|
||||
At boot each app calls `@simpleworkjs/bao-conf`'s `init()`, which deep-merges
|
||||
its OpenBao path over the file-loaded `@simpleworkjs/conf` object — so OpenBao
|
||||
is authoritative at runtime, with the `./config/*-secrets.js` file kept only as
|
||||
an operator-edited seed and a fail-soft fallback (`init()` is fail-soft, so the
|
||||
app still boots from the file if OpenBao is unreachable). The proxy and
|
||||
jump-host consume `conf.oidc.clientSecret` at `require` time, so `init()`
|
||||
runs *before* their models load (see each app's `bin/www`).
|
||||
|
||||
Beyond app config, OpenBao holds:
|
||||
|
||||
- **Per-user secret storage** — `secret/users/<uid>/*`, browsed and edited in
|
||||
the SSO UI's **My Secrets** page. Each user is confined to their own
|
||||
namespace by a `user-<uid>` policy; admins see all of `secret/`.
|
||||
- **External-app tokens** — an admin mints a scoped `app-<name>` token
|
||||
(confined to `secret/apps/<name>/*`) from the SSO UI's **Apps** tab, so an
|
||||
external app can read its own secrets over the OpenBao HTTP API.
|
||||
|
||||
`setup.sh` creates the policies + a `sso-broker` token role and mints the
|
||||
per-app tokens on first run; the root token stays in `setup.env` for
|
||||
seeding/maintenance only and is never passed to a service container. Full
|
||||
details — the policy model, the `secret/apps/<app>/conf` convention, `curl`
|
||||
+ Node examples, and the operator rotation procedure — are in
|
||||
[Secrets](secrets.html).
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +130,14 @@ actual work, running **inside the sso-manager container** (bind-mounted
|
||||
read-only from this repo). It's deliberately self-contained — only Node
|
||||
built-ins (`child_process`, `crypto`, `fs`) + global `fetch`, and it reads its
|
||||
inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets.js`
|
||||
(not from env):
|
||||
(not from env).
|
||||
|
||||
OpenBao comes up first: `setup.sh` initializes and unseals it, writes the
|
||||
policies and the `sso-broker` token role, mints the per-app scoped tokens into
|
||||
`setup.env`, and idempotently seeds `secret/sso-manager/conf`,
|
||||
`secret/proxy/conf`, and `secret/jump-host/conf` from the corresponding
|
||||
`./config/*-secrets.js` files. The app containers then start with their scoped
|
||||
`VAULT_TOKEN`. The SSO/LDAP/OIDC wiring that follows:
|
||||
|
||||
1. **Build + start sso-manager**, wait for `/health`.
|
||||
2. **LDAP service account** — `ldapadd` `cn=ldapclient,ou=people,<base>` (an
|
||||
@@ -97,14 +152,16 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
|
||||
5. **Register the proxy as an OIDC client** via `POST /api/oauth/client` (gated
|
||||
by `app_sso_oauth_admin`, satisfied by step 3). The SSO **generates** the
|
||||
`client_id`/`client_secret` (UUIDs) — supplied creds are ignored — so the
|
||||
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`**
|
||||
(the sso-manager mounts `./config` read-write for this; the proxy mounts it
|
||||
read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret`
|
||||
matching an existing client, they are kept; if the client exists but the file
|
||||
has no usable secret, the secret is rotated and written back.
|
||||
6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint points
|
||||
`CONF_SECRETS` at `./config/proxy-secrets.js`, so `@simpleworkjs/conf`
|
||||
(≥1.2.0) reads the OAuth creds + LDAP bind creds from the file.
|
||||
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`
|
||||
and into OpenBao at `secret/proxy/conf`** (the sso-manager mounts `./config`
|
||||
read-write for this; the proxy mounts it read-only). If `proxy-secrets.js`
|
||||
already holds a `clientId`+`clientSecret` matching an existing client, they
|
||||
are kept; if the client exists but the file has no usable secret, the
|
||||
secret is rotated and written back.
|
||||
6. **Build + start the proxy + jump-host**, wait for `/health`. Each
|
||||
entrypoint points `CONF_SECRETS` at its `./config/*-secrets.js`, then
|
||||
`@simpleworkjs/bao-conf` overlays the OpenBao path over it (the OAuth
|
||||
clientSecret + LDAP bind creds come from OpenBao at runtime).
|
||||
7. **Register `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy** —
|
||||
`setup.sh` runs a short script inside the proxy container that calls its
|
||||
Host model directly (`Host.create({host, ip, targetPort, ...})`), rather
|
||||
@@ -120,20 +177,25 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
|
||||
|
||||
`setup.sh` then prints the first-admin login + the public URLs.
|
||||
|
||||
### How config reaches the apps (no `.env`)
|
||||
### How config reaches the apps
|
||||
|
||||
All config and secrets live in `./config/` (gitignored, bind-mounted). Each
|
||||
entrypoint points the `CONF_SECRETS` env var (`@simpleworkjs/conf` >= 1.2.0)
|
||||
at its file early, before the app starts:
|
||||
Config and secrets live in two layers: an operator-edited
|
||||
`./config/*-secrets.js` file (gitignored, bind-mounted) and the OpenBao
|
||||
overlay over it. Each entrypoint points the `CONF_SECRETS` env var
|
||||
(`@simpleworkjs/conf` >= 1.2.0) at its file early, before the app starts:
|
||||
|
||||
```
|
||||
CONF_SECRETS=/config/sso-secrets.js (sso-manager, ./config RW)
|
||||
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO)
|
||||
CONF_SECRETS=/config/jump-secrets.js (jump-host, ./config RO)
|
||||
```
|
||||
|
||||
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → secrets file → app_*
|
||||
env`, where **env beats the secrets file**. So compose passes **no `app_*` env
|
||||
vars** (only `NODE_ENV`, `NODE_PORT`) — that makes the secrets file
|
||||
env`, where **env beats the secrets file**. Then `@simpleworkjs/bao-conf`
|
||||
deep-merges the app's OpenBao path over the result at boot — OpenBao is the
|
||||
authoritative runtime layer; the file is the seed and fail-soft fallback. So
|
||||
compose passes **no `app_*` config env vars** (only `NODE_ENV`, `NODE_PORT`,
|
||||
`VAULT_ADDR`, and a scoped `VAULT_TOKEN`) — that keeps the secrets file + OpenBao
|
||||
authoritative. The SSO entrypoint reads the few values it needs at startup
|
||||
(LDAP base DN, admin password, JWT secret, cert CN) from `sso-secrets.js` via
|
||||
an in-container `node` call.
|
||||
@@ -151,12 +213,15 @@ end-to-end.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Re-running `./setup.sh` converges to `./config/`:
|
||||
Re-running `./setup.sh` converges to `./config/` + OpenBao:
|
||||
|
||||
- The LDAP service account + admin passwords are **reset to `./config/`**.
|
||||
- Group membership is ensured (add is a no-op if already a member).
|
||||
- The OAuth client is kept if `proxy-secrets.js` already holds its creds;
|
||||
created or rotated otherwise, and the new creds written back.
|
||||
created or rotated otherwise, and the new creds written back (to the file
|
||||
and to OpenBao).
|
||||
- OpenBao policies, token role, per-app tokens, and `secret/<app>/conf` seeds
|
||||
are ensured (created if absent, left alone if present).
|
||||
|
||||
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
|
||||
compose down`, or after restoring from backup.
|
||||
@@ -165,17 +230,39 @@ compose down`, or after restoring from backup.
|
||||
|
||||
## Backups and restore
|
||||
|
||||
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
|
||||
`./setup.sh` auto-snapshots `./config/` + LDAP + all the Redis instances to
|
||||
`./backups/<timestamp>/` before each rebuild (keeps the last `BACKUP_KEEP`,
|
||||
default 5). State lives on named volumes (`ldap-data`, `sso-data`, `proxy-data`)
|
||||
and survives recreation; `down -v` wipes them. Redis is persisted with AOF +
|
||||
RDB on those volumes. For the full manual-backup + restore runbook (full /
|
||||
Redis-only / LDAP-only, with the AOF-vs-RDB note), see the *Backups and
|
||||
restore* section of the [README](https://github.com/theta42/theta-env#backups-and-restore).
|
||||
Quick LDAP backup:
|
||||
default 5). State lives on named volumes (`ldap-data`, `ldap-certs`,
|
||||
`sso-data`, `proxy-data`, `proxy-cache`, `proxy-logs`, `jump-data`,
|
||||
`jump-redis-data`, `openbao-data`) and survives recreation; `down -v` wipes
|
||||
them. Redis is persisted with AOF + RDB on those volumes. For the full
|
||||
manual-backup + restore runbook (full / Redis-only / LDAP-only, with the
|
||||
AOF-vs-RDB note), see the *Backups and restore* section of the
|
||||
[README](https://github.com/theta42/theta-suite#backups-and-restore). OpenBao
|
||||
holds the live secrets, so back up its volume too (`<project>_openbao-data`,
|
||||
where `<project>` is your clone directory name — `theta-suite` for a fresh
|
||||
clone). Quick LDAP backup:
|
||||
|
||||
```bash
|
||||
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Ecosystem
|
||||
|
||||
The SSO Manager utilizes a dynamic plugin registry (`nodejs/services/plugin_registry.js`) that automatically loads any `.js` file placed in the `nodejs/plugins/<category>` folders.
|
||||
|
||||
### Discovery Plugins
|
||||
Discovery plugins (e.g., `nmap.js`, `proxmox.js`, `docker.js`) run on a defined cron schedule to sync external assets into the centralized directory catalog.
|
||||
|
||||
### Messaging Plugins
|
||||
Messaging plugins (e.g., `twilio.js`, `webhook.js`) provide on-demand delivery capabilities for alerts, 2FA tokens, and notifications.
|
||||
- **Universal REST Webhook:** Sends custom JSON payloads to platforms like Slack, Teams, or custom API endpoints securely.
|
||||
- *Discord Example:* To send alerts to a Discord channel, create a new plugin instance of type "Universal REST Webhook". Set the **Webhook URL** to your Discord webhook URL (e.g., `https://discord.com/api/webhooks/...`), the **HTTP Method** to `POST`, and the **Payload Template** to `{"content": "Alert for {{to}}: {{message}}"}`. Leave the Headers and API Secret blank.
|
||||
- **Twilio SMS:** Sends standard SMS codes.
|
||||
- **Fallback:** If no messaging plugins are enabled, the system falls back to the legacy `voipms` integration configured in the SSO secrets.
|
||||
|
||||
Secrets belonging to plugins are automatically pushed to OpenBao (`secret/plugins/<id>/conf`) and are never written to the local database, following the global secrets architecture.
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Canonical demo fixtures
|
||||
---
|
||||
|
||||
# Canonical demo fixtures
|
||||
|
||||
The exact users, groups, and hosts that should exist on a stack used for
|
||||
screenshots or demos, so every future pass seeds the *same* data and a
|
||||
screenshot diff only shows what actually changed in the UI — not incidental
|
||||
differences in who/what happened to exist that day.
|
||||
|
||||
Persona: a single admin/power-user running theta42 across a **big homelab and
|
||||
a small business** — mix of self-hosted infra (Proxmox, Pi-hole, Plex) and
|
||||
office-y apps (invoicing, helpdesk, wiki) with real department structure.
|
||||
|
||||
Domain: `laptop-dev.vm42.us` (real public DNS pointing at this machine — see
|
||||
"Domain" below). Update this doc if the domain ever changes again.
|
||||
|
||||
## Users
|
||||
|
||||
| uid | Name | Department | Password | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `schen` | Sarah Chen | Engineering | `DemoPass123!` | DevOps lead |
|
||||
| `dkim` | David Kim | Engineering | `DemoPass123!` | Backend developer |
|
||||
| `ppatel` | Priya Patel | Engineering | `DemoPass123!` | Frontend developer |
|
||||
| `mjohnson` | Marcus Johnson | Finance | `DemoPass123!` | Finance manager |
|
||||
| `lnguyen` | Linda Nguyen | Finance | `DemoPass123!` | Bookkeeper |
|
||||
| `erodriguez` | Emily Rodriguez | Support | `DemoPass123!` | Support lead |
|
||||
| `tbaker` | Tom Baker | Support | `DemoPass123!` | Support tech |
|
||||
| `jwilson` | James Wilson | Management | `DemoPass123!` | Owner |
|
||||
| `svc-monitoring` | — | service account | `ServiceAcct!2024` | Grafana/Prometheus scraping |
|
||||
| `svc-backup` | — | service account | `ServiceAcct!2024` | Backup automation |
|
||||
|
||||
uidNumbers 5000–5009 in that order. Mail is `<first>.<last>@laptop-dev.vm42.us`
|
||||
(service accounts use their uid, e.g. `monitoring@laptop-dev.vm42.us`).
|
||||
|
||||
## Groups
|
||||
|
||||
`groupOfNames`, owned by `cn=admin,...`, member of the department's users:
|
||||
|
||||
- `engineering` — schen, dkim, ppatel
|
||||
- `finance` — mjohnson, lnguyen
|
||||
- `support` — erodriguez, tbaker
|
||||
- `management` — jwilson
|
||||
- `app_sso_service_account` (built-in) — svc-monitoring, svc-backup
|
||||
|
||||
## Seeding users + groups
|
||||
|
||||
```sh
|
||||
cd theta-env
|
||||
docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh
|
||||
docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh
|
||||
```
|
||||
|
||||
Idempotent — re-running skips anything that already exists. If you add a
|
||||
fixture below, add it to `bootstrap/seed-demo-users.sh` too and keep the two
|
||||
in sync.
|
||||
|
||||
## Proxy hosts
|
||||
|
||||
All under `*.laptop-dev.vm42.us`. `setup.sh` itself creates the first two
|
||||
(sso, proxy) — everything else below is added by hand through Hosts → Add
|
||||
host (Proxy UI, currently no seed script — see note at the bottom).
|
||||
|
||||
| Host | Target | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| `sso` | `sso-manager:3001` | — | created by `setup.sh` |
|
||||
| `proxy` | `127.0.0.1:3000` | — | created by `setup.sh` |
|
||||
| `jump` | `jump-host:3002` | — | created by `setup.sh` |
|
||||
| `proxmox` | `10.0.10.5:8006` (HTTPS) | Basic — realm "Proxmox VE", users `dkim`, `schen` | |
|
||||
| `pbs` | `10.0.10.6:8007` (HTTPS) | Basic — realm "Proxmox Backup Server", user `dkim` | |
|
||||
| `grafana` | `10.0.10.12:3000` + LB target `10.0.10.13:3000` | SSO — group `engineering` | load-balancing example |
|
||||
| `nextcloud` | `10.0.10.20:80` | SSO — any authenticated user | empty allow-lists |
|
||||
| `ha` | `10.0.10.30:8123` | Basic — realm "Home Assistant", user `jwilson` | |
|
||||
| `jenkins` | `10.0.10.40:8080` | SSO — group `engineering` | |
|
||||
| `gitea` | `10.0.10.41:3000` | Off (public) | has its own login |
|
||||
| `plex` | `10.0.10.50:32400` | Off (public) | has its own login |
|
||||
| `nas` | `10.0.10.60:5001` (HTTPS) | Basic — realm "Synology NAS", user `jwilson` | |
|
||||
| `pihole` | `10.0.10.61:80` | Basic — realm "Pi-hole Admin", user `dkim` | |
|
||||
| `wiki` | `10.0.10.70:3000` | SSO — any authenticated user | |
|
||||
| `invoices` | `10.0.10.80:8000` | SSO — group `finance` | small-business flavor |
|
||||
| `helpdesk` | `10.0.10.81:3000` | SSO — group `support` | small-business flavor |
|
||||
|
||||
Basic-auth passwords used: `dkim:HomeLab!2024`, `schen:Engineering!24`,
|
||||
`jwilson:HomeOwner!24`.
|
||||
|
||||
## Domain
|
||||
|
||||
`CFG_DOMAIN=laptop-dev.vm42.us` in `setup.env`, real public DNS (CNAME
|
||||
through `718it.biz`) that resolves back to this machine. `CFG_LDAPS_HOST`
|
||||
is pinned to the LAN IP of the interface holding the default route
|
||||
(`ip route get 1.1.1.1`), not just any active interface — this machine had
|
||||
two (wifi + USB ethernet) and only one was actually externally reachable
|
||||
through the existing port-forward/prod-proxy setup.
|
||||
|
||||
A production reverse proxy in front of this host handles TLS/ACME for
|
||||
`*.718it.biz`-family domains (to avoid hitting Let's Encrypt's rate limits
|
||||
re-provisioning a cert every time this dev stack rebuilds) — if a fresh
|
||||
rebuild's Host records don't resolve correctly from the public domain right
|
||||
after `setup.sh`, that's the layer to check, not this stack's own nginx/lua
|
||||
routing. `curl -sk -D - https://sso.laptop-dev.vm42.us/` from the host
|
||||
machine is the fastest way to confirm whether the issue is server-side.
|
||||
|
||||
## Known-good login shortcuts
|
||||
|
||||
Skip SSO's self-signed-cert dance entirely for admin/screenshot work — every
|
||||
app ships a local anti-lockout admin account for exactly this:
|
||||
|
||||
```sh
|
||||
# SSO Manager admin (bootstrap account, uid "admin")
|
||||
node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)"
|
||||
|
||||
# Proxy — username proxyadmin2
|
||||
node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)"
|
||||
|
||||
# Jump-host — username jumpadmin
|
||||
node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)"
|
||||
```
|
||||
|
||||
Ports (from `setup.env` — check it, these are operator-configurable):
|
||||
SSO `3001`, Proxy management UI `3010` (`MGMT_PORT`), Jump-host `3002`.
|
||||
|
||||
A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS,
|
||||
enter a DOB) before the rest of the UI is usable — expect that on a stack
|
||||
that was just rebuilt from scratch.
|
||||
|
||||
## Jump-host access (SSO Directory resource)
|
||||
|
||||
Jump-host's dashboard ("Hosts you can reach") is **not** driven by Proxy's
|
||||
Host records — it resolves access via the SSO Manager's own Directory
|
||||
(`kind: host` resources), filtered by the logged-in user's LDAP group
|
||||
membership. This is a completely separate system from Proxy's HTTP-routing
|
||||
hosts above; a Proxy host existing does not make it SSH-reachable through
|
||||
jump-host.
|
||||
|
||||
For a `dkim`-can-reach-something screenshot, one Directory host resource was
|
||||
added:
|
||||
|
||||
- **Directory → Add Resource**: name `proxmox-node`, kind `Host`, IP
|
||||
`10.0.10.5`, parent resource `local (site_local)`.
|
||||
- **Associated LDAP Groups → `site_local_host_proxmox-node_access` →
|
||||
Members → Add member → `dkim`** (added the individual user directly, not
|
||||
the `engineering` group — the resource's own auto-generated `_access`
|
||||
group's member picker only offers individual users).
|
||||
|
||||
To reproduce: repeat those two steps for `proxmox-node` if it's missing, or
|
||||
add more Directory host resources the same way for a richer "Hosts you can
|
||||
reach" list.
|
||||
|
||||
**To screenshot as a real fixture user** (not the `jumpadmin` local
|
||||
anti-lockout admin, whose "My hosts" list is always non-empty by virtue of
|
||||
infra ownership, not a real access grant): log out, click "Log in with
|
||||
Jump" on the login page, and sign in as `dkim` / `DemoPass123!` through the
|
||||
real SSO flow. This exercises the actual OIDC redirect through
|
||||
`sso.laptop-dev.vm42.us` — by this point in the session it worked cleanly in
|
||||
the browser; if it doesn't (stale cookies/redirect loop from an earlier bad
|
||||
state), see `docs/screenshots.md` §2 for the fallback.
|
||||
|
||||
## What's not yet automated
|
||||
|
||||
Proxy hosts are still added by hand (no `seed-demo-hosts.sh` equivalent) —
|
||||
the Proxy UI has no simple LDIF-style bulk-import path the way LDAP does, and
|
||||
scripting it means either driving the browser or reverse-engineering the
|
||||
session-cookie login flow for curl. If this list changes often enough to be
|
||||
annoying, that's the next thing worth building — a small node script run via
|
||||
`docker compose exec proxy node ...` calling the `Host` model directly,
|
||||
mirroring how `setup.sh`'s own step 7 registers the sso/proxy hosts.
|
||||
|
||||
## Screenshot workflow
|
||||
|
||||
See `docs/screenshots.md` for the full screenshot-capture workflow
|
||||
(save-to-disk, where each doc image lives, the app.modal.js browser-cache
|
||||
gotcha). Once fixtures match this doc, only re-screenshot pages whose UI
|
||||
actually changed since the last pass — the data itself shouldn't be the
|
||||
reason a screenshot looks different.
|
||||
|
After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 332 KiB |
@@ -4,18 +4,29 @@ title: Home
|
||||
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses. Wires together a self-hosted identity provider and a reverse proxy with one setup.sh.
|
||||
---
|
||||
|
||||
# theta-env
|
||||
# Theta suite
|
||||
|
||||
The whole theta42 identity + access stack in one repo, brought up with a
|
||||
single command — for home labs and small businesses.
|
||||
Theta Suite is your one-line solution to replacing fragmented, hard-to-wire
|
||||
authentication setups with a unified security stack. It wires together OIDC
|
||||
authentication, LDAP user directories, automated host enrollment, and
|
||||
centralized secret management in a single command. It eliminates the manual
|
||||
configuration friction so you get secure access, auditability, and
|
||||
[multi-site](sso/multi-site.html) replication when you need more than one
|
||||
location.
|
||||
|
||||
It wires together two projects that already work on their own —
|
||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
|
||||
LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an
|
||||
OIDC-protected reverse proxy that can also look users up directly in LDAP) —
|
||||
and automates the fiddly part: registering the proxy as an OIDC client of the
|
||||
SSO and pointing it at the right LDAP directory, with hostnames and secrets
|
||||
generated from one `setup.env`.
|
||||
## Who This Is For
|
||||
* **Self-Hosters & Homelab Engineers:** Anyone running local bare metal,
|
||||
Proxmox, or private VPS nodes who wants enterprise-grade OIDC, multi-master
|
||||
LDAP, PAM/SSSD host enrollment, and OpenBao secret management without spending
|
||||
days manually wiring glue code.
|
||||
* **Small-to-Medium Businesses (SMBs):** Infrastructure teams that need a
|
||||
unified, directory-driven access plane across both web apps and Linux boxes,
|
||||
but want to bypass per-user SaaS taxes (Okta, Azure AD) and cloud vendor
|
||||
lock-in.
|
||||
* **DevOps & Systems Operators:** Engineers who value idempotent, single-command
|
||||
deployments (`./setup.sh`) and need a production-grade baseline supporting
|
||||
zero-trust proxying, SSH jump-host access control, and
|
||||
[multi-site](sso/multi-site.html) replication out of the box.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -23,41 +34,68 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
|
||||
|
||||
<a href="images/sso-dashboard.png" target="_blank"><img src="images/sso-dashboard.png" alt="SSO Manager dashboard" width="49%"></a>
|
||||
<a href="images/proxy-hosts.png" target="_blank"><img src="images/proxy-hosts.png" alt="Proxy host list" width="49%"></a>
|
||||
<a href="images/jump-dashboard.png" target="_blank"><img src="images/jump-dashboard.png" alt="Jump Host dashboard" width="49%"></a>
|
||||
|
||||
*(click either screenshot to view full size)*
|
||||
|
||||
## Why this over running them separately
|
||||
|
||||
Each project works standalone, but they only become useful together once the
|
||||
proxy is registered as an OIDC client of the SSO *and* pointed at the SSO's
|
||||
LDAP directory — and the domain has to match across half a dozen config
|
||||
fields, or logins silently fail. Doing that by hand is fiddly. `setup.sh`
|
||||
asks for your domain once, generates both apps' config with it filled in
|
||||
everywhere, registers the proxy as an OIDC client automatically, and
|
||||
snapshots state before every rebuild.
|
||||
|
||||
## What you get
|
||||
|
||||
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
|
||||
and OAuth clients.
|
||||
- **Proxy** — add the hosts you want to protect with OIDC login.
|
||||
- **LDAPS** for legacy apps that bind directly.
|
||||
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a
|
||||
browser session.
|
||||
- **Unified SSO Manager**: An OpenID Connect (OIDC) provider and OAuth 2.0
|
||||
authorization server fronted by TLS. Includes a web dashboard for managing
|
||||
users, groups, and OAuth apps, plus automated invitation and password reset
|
||||
flows.
|
||||
- **Identity-Aware Reverse Proxy**: Intercepts HTTP/HTTPS traffic to protect
|
||||
upstream applications with OIDC login and direct LDAP group authorization,
|
||||
featuring automatic TLS certificate issuance and automated host routing.
|
||||
- **Embedded LDAPS Directory**: A bundled OpenLDAP core acting as your single
|
||||
source of truth for POSIX accounts, SSH public keys, and sudo roles. Native
|
||||
apps, legacy infrastructure, and Linux machines authenticate directly over
|
||||
encrypted LDAPS (port 636) or StartTLS.
|
||||
- **Hierarchical Directory Group & Permission Model**: Every adopted application
|
||||
and machine automatically inherits dedicated `admin`, `access`, and
|
||||
`capability` groups generated directly from the LDAP directory. These map
|
||||
cleanly to real POSIX groups for fine-grained sudo and SSH privilege controls.
|
||||
See [Group & Permission Model](GROUPS.html).
|
||||
- **Automated Linux Host Enrollment (ldap-client)**: A lightweight host agent
|
||||
that enrolls Linux machines into the central directory. It configures system
|
||||
PAM/SSSD for login, applies sudo policies, distributes SSH public keys, and
|
||||
registers host telemetry in the primary inventory dashboard.
|
||||
- **Directory-Driven SSH Jump Host**: A centralized bastion host that routes
|
||||
inbound terminal traffic (`ssh uid_-_host@jump.<domain>`) using active
|
||||
directory group memberships. Supports WinSCP, file transfers, interactive
|
||||
host pickers, and a dedicated audit interface for tracking user sessions and
|
||||
connection metrics.
|
||||
- **Central Secrets Engine (OpenBao integration)**: Bootstraps every component
|
||||
against an embedded [OpenBao](https://openbao.org/) instance to load tokens
|
||||
and cryptographic keys at runtime. Provides per-user secret vaults and enables
|
||||
administrators to mint scoped API tokens for external services. See
|
||||
[Secrets](secrets.html).
|
||||
- **Self-Service & CI/CD API Tokens**: Granular, personal access token
|
||||
management built directly into the web interface, allowing operators to drive
|
||||
system administration and automation pipelines programmatically without an
|
||||
active browser session.
|
||||
- **Multi-Site Geo-Replication**: Built-in support for N-Way Multi-Master LDAP
|
||||
replication, allowing directory states to sync across geographically separated
|
||||
physical hardware or remote data centers for high availability and low-latency
|
||||
local reads.
|
||||
- **Multi-Target Load Balancing**: Native reverse-proxy load balancing that
|
||||
distributes traffic across multiple application backends using customizable
|
||||
health checks and round-robin strategies.
|
||||
|
||||
## Get it
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/theta42/theta-env.git
|
||||
cd theta-env
|
||||
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||
cd theta-suite
|
||||
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
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
|
||||
**[GitHub repository](https://github.com/theta42/theta-env)**.
|
||||
You need a **Domain**, some **Ports Forwarded**, **Docker** +
|
||||
**Docker Compose**. `./setup.sh` is idempotent — re-run any time to converge the
|
||||
stack to `./config/`. For the full config reference, architecture, see the
|
||||
**[GitHub repository](https://github.com/theta42/theta-suite#before-you-begin)**
|
||||
for a details.
|
||||
|
||||
## Related projects
|
||||
|
||||
@@ -65,3 +103,7 @@ architecture, and running each project standalone, see the
|
||||
provider + LDAP directory this stack runs.
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
|
||||
stack runs in front of it.
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — the SSH jump
|
||||
host this stack brings up.
|
||||
- **[ldap-client](https://theta42.github.io/ldap-client/)** — enrolls Linux
|
||||
hosts into the directory this stack serves.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
|
||||
|
||||
{% seo title=false %}
|
||||
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid px-3">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
||||
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
||||
{{ site.title }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
||||
<ul class="navbar-nav">
|
||||
{% for item in site.nav %}
|
||||
<li class="nav-item">
|
||||
{% if item.page %}
|
||||
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
||||
<div class="container-fluid py-4 py-md-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-body p-4 p-md-5 site-content">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-3 bg-dark text-light mt-auto">
|
||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<a href="https://theta42.com" target="_blank" rel="noopener">
|
||||
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
||||
</a>
|
||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
||||
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-solid fa-list"></i> Changelog
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
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=<cn>` (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 `<slug>_access` / `<slug>_admin` groups for every
|
||||
> host and service (see Theta Directory's
|
||||
> [Directory & Inventory](../sso/directory.html)
|
||||
> docs), which is exactly what this authorization reads.
|
||||
|
||||
## 3. Upstream Authentication (PKI or LDAP Keys) {#upstream-authentication}
|
||||
|
||||
To connect downstream *as the user* without asking them for a password, the jump host uses one of two methods (configured in `conf.ssh`):
|
||||
|
||||
**Option A: PKI Certificates (Recommended)**
|
||||
The jump host securely calls the OpenBao (Vault) SSH Secrets Engine API to request a short-lived (e.g. 5-minute), signed SSH certificate for the target user.
|
||||
- **Zero Touch on Target:** The target host simply trusts the OpenBao CA (`TrustedUserCAKeys /etc/ssh/ca.pub`). No public keys are synced or managed.
|
||||
- **Ephemeral:** The certificate expires automatically.
|
||||
- **Transparent:** The jump host passes `cert: signedCert` to `ssh2.Client`, authenticating instantly.
|
||||
|
||||
**Option B: Legacy LDAP Key Injection**
|
||||
If PKI is not configured, the jump host falls back to its legacy method: it holds **one** keypair. 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 the downstream host's `AuthorizedKeysCommand` will accept it, then connects 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 needs **write access** to the `sshPublicKey` attribute.
|
||||
- Because the marker key is excluded from inbound auth (step 1), it grants only the jump host's onward path.
|
||||
|
||||
## 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 Theta Directory and Theta 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
|
||||
|
||||
- **[Theta Directory](../sso/)** — 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.
|
||||
- **[Theta Proxy](../proxy/)** — fronts the jump host's web UI
|
||||
under TLS.
|
||||
- **theta-suite** — wires it all together.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Background circle -->
|
||||
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
|
||||
|
||||
<!-- Network nodes -->
|
||||
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
|
||||
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
|
||||
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
|
||||
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
|
||||
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
|
||||
|
||||
<!-- Connection lines -->
|
||||
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#C59341" />
|
||||
<stop offset="20%" stop-color="#E4B869" />
|
||||
<stop offset="40%" stop-color="#FBF0B9" />
|
||||
<stop offset="60%" stop-color="#DFB260" />
|
||||
<stop offset="80%" stop-color="#BC8837" />
|
||||
<stop offset="100%" stop-color="#A36F28" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="40%" stop-color="#F5E3B5" />
|
||||
<stop offset="70%" stop-color="#D4A343" />
|
||||
<stop offset="100%" stop-color="#8A5A16" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#drop-shadow)">
|
||||
<g fill="url(#gold-grad)">
|
||||
<path d="M 200,40
|
||||
C 290,40 350,110 350,200
|
||||
C 350,290 290,360 200,360
|
||||
C 110,360 50,290 50,200
|
||||
C 50,110 110,40 200,40 Z
|
||||
M 200,75
|
||||
C 130,75 88,130 88,200
|
||||
C 88,270 130,325 200,325
|
||||
C 270,325 312,270 312,200
|
||||
C 312,130 270,75 200,75 Z"
|
||||
fill-rule="evenodd" />
|
||||
|
||||
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||
|
||||
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||
</g>
|
||||
|
||||
<text x="200" y="222"
|
||||
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="78"
|
||||
font-weight="900"
|
||||
fill="url(#text-grad)"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-2">42</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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_<name>_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.
|
||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,91 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: Theta Gateway — an SSH jump host for theta-suite, giving directory-driven access to every downstream machine you're entitled to from one public host.
|
||||
---
|
||||
|
||||
# Theta Gateway
|
||||
|
||||
The SSH jump host component of [theta-suite](../). Users SSH into **one**
|
||||
public host and land on any downstream host they're entitled to —
|
||||
authenticated against the shared LDAP directory, authorized from
|
||||
[Theta Directory](../sso/)'s inventory graph, and audited end to end.
|
||||
|
||||
No per-host accounts, no distributing keys, no VPN. The same people who log in
|
||||
to Theta Directory are the people who can reach your machines — and only the
|
||||
machines their directory groups grant.
|
||||
|
||||
Theta Gateway is deployed as part of theta-suite, alongside
|
||||
[Theta Directory](../sso/) and [Theta Proxy](../proxy/) — it isn't installed
|
||||
or run on its own. See the [Quickstart](../quickstart.html) to stand up the
|
||||
whole stack with one command.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
||||
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
|
||||
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
|
||||
|
||||
*(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.
|
||||
|
||||
Theta Gateway answers both from your directory:
|
||||
|
||||
- **Authorization is your directory graph.** The hosts you can reach are the
|
||||
union of your LDAP groups × Theta Directory's inventory (the
|
||||
`host_<name>_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.** Theta Gateway 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'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 Theta Directory
|
||||
inventory, not a static list
|
||||
- **Per-user key injection** — no downstream changes, no key distribution
|
||||
- **Shell, exec, and SFTP** bridging
|
||||
- **[WireGuard mesh routing](mesh.html)** — cross-site network access alongside SSH
|
||||
- **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
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
layout: default
|
||||
title: Gateway Mesh
|
||||
---
|
||||
|
||||
# Gateway Mesh
|
||||
|
||||
Theta Gateway can mesh with other Theta Gateway instances over real
|
||||
site-to-site WireGuard tunnels — separate from its [SSH jump
|
||||
host](connecting.html) role, and separate from the roaming-client/exit-node
|
||||
WireGuard feature (individual peer configs for laptops/phones). This is
|
||||
gateway-to-gateway: two sites' networks reaching each other directly.
|
||||
|
||||
## Why and when to use this
|
||||
|
||||
- **Direct site-to-site networking**, not just SSH. Once two gateways are
|
||||
meshed, hosts behind each can reach each other over the tunnel using the
|
||||
mesh addressing scheme below — not limited to jumping through SSH.
|
||||
- **No manual WireGuard config.** Meshing is a join-token exchange; both
|
||||
sides come out with a live, working peer entry for each other
|
||||
automatically.
|
||||
- **Works without a kernel WireGuard module.** Prefers in-kernel WireGuard,
|
||||
falls back to the userspace `wireguard-go` implementation automatically —
|
||||
useful for older kernels, some container/cloud images, or hosts where the
|
||||
kernel module isn't available.
|
||||
|
||||
## How it works
|
||||
|
||||
1. On the gateway you want others to join, mint a join token: **Mesh** page
|
||||
→ **Mint a Join Token**. It's single-use and expires in 15 minutes.
|
||||
2. On the new gateway, use **Join a Remote Gateway's Mesh**: paste the other
|
||||
gateway's URL and the token.
|
||||
3. Both sides now have a live WireGuard peer for each other. The **Meshed
|
||||
Gateways** table shows every peer, its assigned mesh subnet, and when it
|
||||
was last seen.
|
||||
|
||||
Each gateway is assigned a **mesh index** (an integer 1–254) the first time
|
||||
it either mints a token or is registered by another gateway. That index
|
||||
determines its subnet: `172.24.<index>.0/24` for the mesh tunnel itself, plus
|
||||
`10.<index>.0.0/16` reserved for that site's own local network — 254 sites is
|
||||
the hard ceiling this addressing scheme supports.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Both gateways need a reachable endpoint (host:port) for the WireGuard
|
||||
handshake — typically the same public host the SSH/web ports are already
|
||||
on, with UDP 51820 reachable.
|
||||
- `NET_ADMIN` capability (or equivalent) on the container/host running the
|
||||
gateway, to create the WireGuard interface.
|
||||
|
||||
## Connected to directory sync
|
||||
|
||||
[Theta Directory's multi-site join](../sso/multi-site.html) (catalog + LDAP
|
||||
replication between a master and its spokes) prefers this mesh once it's up:
|
||||
a spoke that's registered a mesh IP gets its live resync pushes routed over
|
||||
the tunnel instead of the open internet, falling back to its public endpoint
|
||||
if the mesh path fails. A spoke with no public IP at all can also register as
|
||||
no-inbound (`CFG_SPOKE_NO_INBOUND` in `theta-suite`'s `setup.env`) so the
|
||||
master auto-creates a relay route through its own `theta-proxy` — the master
|
||||
terminates TLS for that spoke's hostname and relays over this mesh. The mesh
|
||||
peering itself (this page) stays a manual step on both sides; directory join
|
||||
and relay registration pick up from there. See the [architecture
|
||||
spec](https://github.com/theta42/theta-suite/blob/master/docs/MULTI_SITE_SPEC.md)
|
||||
for the full detail.
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
|
||||
|
||||
{% seo title=false %}
|
||||
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid px-3">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
||||
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
||||
{{ site.title }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
||||
<ul class="navbar-nav">
|
||||
{% for item in site.nav %}
|
||||
<li class="nav-item">
|
||||
{% if item.page %}
|
||||
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
||||
<div class="container-fluid py-4 py-md-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-body p-4 p-md-5 site-content">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-3 bg-dark text-light mt-auto">
|
||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<a href="https://theta42.com" target="_blank" rel="noopener">
|
||||
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
||||
</a>
|
||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
||||
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-solid fa-list"></i> Changelog
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 <token>` 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_<id>_<secret>", "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/<id>
|
||||
```
|
||||
|
||||
**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/<id>
|
||||
```
|
||||
|
||||
**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/<id>
|
||||
```
|
||||
|
||||
**Responses:**
|
||||
- `200` `{"id": "<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/<id>/rotate
|
||||
```
|
||||
|
||||
**Responses:**
|
||||
- `200` `{"token": "prx_<id>_<new-secret>", "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/<id>
|
||||
```
|
||||
|
||||
**Responses:**
|
||||
- `200` `{"message": "Permission <id> 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/<id>/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/<id>
|
||||
```
|
||||
|
||||
**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 <token>` 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
|
||||
@@ -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_<hostname> # Host configuration
|
||||
proxy_User_<username> # User account
|
||||
proxy_AuthToken_<token> # Auth tokens
|
||||
proxy_DnsProvider_<id> # DNS provider
|
||||
proxy_Domain_<domain> # Domain info
|
||||
<hostname>: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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Background circle -->
|
||||
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
|
||||
|
||||
<!-- Network nodes -->
|
||||
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
|
||||
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
|
||||
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
|
||||
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
|
||||
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
|
||||
|
||||
<!-- Connection lines -->
|
||||
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#C59341" />
|
||||
<stop offset="20%" stop-color="#E4B869" />
|
||||
<stop offset="40%" stop-color="#FBF0B9" />
|
||||
<stop offset="60%" stop-color="#DFB260" />
|
||||
<stop offset="80%" stop-color="#BC8837" />
|
||||
<stop offset="100%" stop-color="#A36F28" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="40%" stop-color="#F5E3B5" />
|
||||
<stop offset="70%" stop-color="#D4A343" />
|
||||
<stop offset="100%" stop-color="#8A5A16" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#drop-shadow)">
|
||||
<g fill="url(#gold-grad)">
|
||||
<path d="M 200,40
|
||||
C 290,40 350,110 350,200
|
||||
C 350,290 290,360 200,360
|
||||
C 110,360 50,290 50,200
|
||||
C 50,110 110,40 200,40 Z
|
||||
M 200,75
|
||||
C 130,75 88,130 88,200
|
||||
C 88,270 130,325 200,325
|
||||
C 270,325 312,270 312,200
|
||||
C 312,130 270,75 200,75 Z"
|
||||
fill-rule="evenodd" />
|
||||
|
||||
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||
|
||||
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||
</g>
|
||||
|
||||
<text x="200" y="222"
|
||||
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="78"
|
||||
font-weight="900"
|
||||
fill="url(#text-grad)"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-2">42</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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 Theta Directory (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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 440 KiB |
@@ -0,0 +1,51 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: Theta Proxy — 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.
|
||||
---
|
||||
|
||||
# Theta Proxy
|
||||
|
||||
The reverse proxy and HTTPS termination component of [theta-suite](../), built
|
||||
on OpenResty/nginx. 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
|
||||
[Theta Directory](../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.
|
||||
|
||||
Theta Proxy is deployed as part of theta-suite, alongside
|
||||
[Theta Directory](../sso/) and [Theta Gateway](../jump-host/) — it isn't
|
||||
installed or run on its own. See the [Quickstart](../quickstart.html) to stand
|
||||
up the whole stack with one command.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/hosts.png" target="_blank"><img src="images/hosts.png" alt="Host list" width="49%"></a>
|
||||
<a href="images/host-auth-sso.png" target="_blank"><img src="images/host-auth-sso.png" alt="Per-host SSO auth" width="49%"></a>
|
||||
|
||||
Basic auth and SSO are mutually exclusive per host, with per-user password
|
||||
management once basic auth is enabled:
|
||||
|
||||
<a href="images/host-auth-basic.png" target="_blank"><img src="images/host-auth-basic.png" alt="Per-host basic auth" width="60%"></a>
|
||||
|
||||
Multiple backend targets per host, load balanced round-robin:
|
||||
|
||||
<a href="images/load-balancing.png" target="_blank"><img src="images/load-balancing.png" alt="Load balancing" width="60%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
## 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, against [Theta Directory](../sso/)
|
||||
- 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
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Quickstart
|
||||
description: Step-by-step first run for theta-env — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
|
||||
description: Step-by-step first run for theta-suite — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
|
||||
---
|
||||
|
||||
# Quickstart Guide
|
||||
@@ -12,8 +12,7 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
|
||||
compose` or the v1 standalone `docker-compose` both work).
|
||||
- A Linux host with **Docker + Docker Compose** (you must use the modern `docker compose` v2 plugin; the older `docker-compose` v1 standalone will fail on BuildKit images).
|
||||
- Two hostnames that resolve to the host: one for the SSO UI (your `stack.ssoHost`),
|
||||
one for the proxy mgmt UI (your `stack.proxyHost`). On a real network add DNS
|
||||
records; for a local try, add them to `/etc/hosts`.
|
||||
@@ -26,8 +25,8 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
|
||||
## 1. Clone
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/theta42/theta-env.git
|
||||
cd theta-env
|
||||
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||
cd theta-suite
|
||||
```
|
||||
|
||||
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
|
||||
@@ -60,6 +59,8 @@ setups `CFG_DOMAIN` is the only value you set:
|
||||
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
|
||||
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional |
|
||||
| `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN |
|
||||
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` (the [SSH jump host](https://theta42.github.io/jump-host/) is installed + started by default) |
|
||||
| `JUMP_SSH_PORT` | `2222` | optional: host port for the jump host's SSH (never 22 by default) |
|
||||
|
||||
`setup.env` is used **only on the first run** to generate `./config/`; after
|
||||
that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets
|
||||
@@ -139,11 +140,19 @@ then converges the stack to your `./config/` values (LDAP service account + admi
|
||||
passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js`
|
||||
already holds its creds).
|
||||
|
||||
> **Troubleshooting: "A newer version is available" after running setup.sh?**
|
||||
> If the UI shows this warning immediately after you ran `./setup.sh`, the latest
|
||||
> GitHub release tag might not yet be merged into the default tracking branch for
|
||||
> the submodules, or Docker may have cached the `COPY` step if the `package.json`
|
||||
> didn't change. You can force a clean rebuild by running
|
||||
> `docker compose build --no-cache` and then re-running `./setup.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Direct LDAP for legacy apps
|
||||
## Direct LDAP for LDAP-native clients and Linux hosts
|
||||
|
||||
Legacy apps bind LDAP directly over LDAPS:
|
||||
LDAP-native apps and Linux hosts (PAM/SSSD, sudo, SSH keys) bind LDAP directly
|
||||
over LDAPS:
|
||||
|
||||
```bash
|
||||
ldapsearch -x -H ldaps://<host>:636 \
|
||||
@@ -162,7 +171,7 @@ or the admin DN. Use LDAPS (636), not plain LDAP.
|
||||
before each rebuild (keeps the last `BACKUP_KEEP`, default 5). For manual
|
||||
backups and the full restore runbook (full / Redis-only / LDAP-only, with the
|
||||
AOF-vs-RDB note), see the *Backups and restore* section of the
|
||||
[README](https://github.com/theta42/theta-env#backups-and-restore). Quick LDAP
|
||||
[README](https://github.com/theta42/theta-suite#backups-and-restore). Quick LDAP
|
||||
backup:
|
||||
|
||||
```bash
|
||||
@@ -180,7 +189,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)
|
||||
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
|
||||
Sitemap: https://theta42.github.io/theta-suite/sitemap.xml
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Updating gitpages screenshots
|
||||
---
|
||||
|
||||
# Updating gitpages screenshots
|
||||
|
||||
How to refresh the docs/images/*.png screenshots across sso-manager-node,
|
||||
proxy, jump-host, and theta-env's own docs. This comes up periodically as the
|
||||
UI changes — this doc + `docs/fixtures.md` + `bootstrap/seed-demo-users.sh`
|
||||
exist so it doesn't have to be re-figured-out from scratch each time. Once
|
||||
fixtures match `docs/fixtures.md`, you only need to re-screenshot pages whose
|
||||
UI actually changed since the last pass.
|
||||
|
||||
## 1. Seed realistic demo data
|
||||
|
||||
Screenshots should show a believable homelab/small-business setup, not empty
|
||||
tables or `test`/`vaulttest` accounts, and the **same** cast every time — see
|
||||
`docs/fixtures.md` for the canonical list (exact users, groups, hosts,
|
||||
passwords) and keep it in sync with what's actually seeded. Seed users +
|
||||
groups with:
|
||||
|
||||
```sh
|
||||
docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh
|
||||
docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh
|
||||
```
|
||||
|
||||
Idempotent — safe to re-run, existing entries are skipped. Proxy hosts have
|
||||
no equivalent script yet — add them by hand through the Proxy UI (Hosts →
|
||||
Add host), following `docs/fixtures.md`'s host table exactly (same hostnames,
|
||||
targets, auth config every time).
|
||||
|
||||
## 2. Logging in without fighting SSO/TLS
|
||||
|
||||
The SSO's own domain goes through real DNS + a production reverse proxy in
|
||||
front of this dev stack (see `docs/fixtures.md` → Domain) — logging in via
|
||||
"Log in with SSO" from Proxy/Jump-host round-trips through that whole path
|
||||
and can hit stale-cookie/redirect-loop artifacts in an automation browser
|
||||
profile that a real browser wouldn't. Don't fight this — every app ships a
|
||||
local anti-lockout admin for exactly this situation. Read the password
|
||||
straight out of the mounted secrets:
|
||||
|
||||
```sh
|
||||
# SSO Manager admin (bootstrap account, uid "admin")
|
||||
node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)"
|
||||
|
||||
# Proxy — username proxyadmin2
|
||||
node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)"
|
||||
|
||||
# Jump-host — username jumpadmin
|
||||
node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)"
|
||||
```
|
||||
|
||||
Log in at `http://localhost:<port>/login` for each app — plain HTTP on the
|
||||
mapped port, no cert/cookie issues at all. Ports come from `setup.env`
|
||||
(operator-configurable) — check it rather than assuming defaults; e.g. this
|
||||
deployment maps the Proxy UI to `3010` (`MGMT_PORT`), not the usual `3000`.
|
||||
|
||||
**Don't touch the login form if it autofills a real saved username/password**
|
||||
(Chrome profile password manager) — clear the fields and type the local admin
|
||||
credentials above instead. Never submit a real saved credential on the
|
||||
user's behalf.
|
||||
|
||||
A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS,
|
||||
enter a DOB) before the rest of the UI is usable — expect that right after a
|
||||
from-scratch rebuild.
|
||||
|
||||
## 3. Known gotcha: stale `app.modal.js` in the browser cache
|
||||
|
||||
If "Add host" (or any `app.modal`-based modal) opens with tabs/fields but no
|
||||
Save/Cancel footer, check the console for
|
||||
`TypeError: app.modal.on is not a function`. That means the browser has an
|
||||
HTTP-cached copy of `@simpleworkjs/frontend/lib/app.modal.js` from before a
|
||||
method (`on`, `showTab`, etc.) was added — `curl`-ing the same URL returns the
|
||||
current file, so it's a caching artifact, not a real app bug. Fix it in-page
|
||||
without a full hard-reload cycle:
|
||||
|
||||
```js
|
||||
// via the browser automation JS tool, in the page context
|
||||
const res = await fetch('/static-modules/@simpleworkjs/frontend/lib/app.modal.js', {cache: 'reload'});
|
||||
await res.text(); // {cache:'reload'} both bypasses AND refreshes the cache entry
|
||||
```
|
||||
|
||||
Then reload the page normally — the fresh file sticks for the rest of the
|
||||
session.
|
||||
|
||||
## 4. Capture screenshots
|
||||
|
||||
Use `save_to_disk: true` on the browser screenshot action so files land on
|
||||
disk instead of just being viewed inline. One screenshot per doc image:
|
||||
|
||||
| File | Page |
|
||||
|---|---|
|
||||
| `sso-manager-node/docs/images/dashboard.png` | SSO Catalog (`/`) |
|
||||
| `sso-manager-node/docs/images/users.png` | SSO Users → People (`/users`) |
|
||||
| `sso-manager-node/docs/images/directory.png` | SSO Directory (`/directory`) |
|
||||
| `sso-manager-node/docs/images/groups.png` | A user's profile → "My Groups" tab |
|
||||
| `sso-manager-node/docs/images/oauth-clients.png` | Directory → an `oauth` resource → Edit → Details tab |
|
||||
| `proxy/docs/images/hosts.png` | Proxy Hosts list (`/hosts`) |
|
||||
| `proxy/docs/images/host-auth-basic.png` | Edit a basic-auth host → Authentication tab |
|
||||
| `proxy/docs/images/host-auth-sso.png` | Edit an SSO-auth host → Authentication tab |
|
||||
| `proxy/docs/images/load-balancing.png` | Edit a host with "Additional Targets" filled in → General tab |
|
||||
| `jump-host/docs/images/login.png` | Jump-host login page |
|
||||
| `jump-host/docs/images/dashboard.png` | Jump-host dashboard, logged in as a real fixture user (e.g. `dkim` via SSO) with an actual access grant — not the `jumpadmin` local admin, whose host list isn't representative. See `docs/fixtures.md` → Jump-host access. |
|
||||
| `jump-host/docs/images/sessions.png` | Jump-host active sessions |
|
||||
| `jump-host/docs/images/audit.png` | Jump-host audit log |
|
||||
| `theta-env/docs/images/sso-dashboard.png` | same as SSO Catalog above |
|
||||
| `theta-env/docs/images/proxy-hosts.png` | same as Proxy Hosts above |
|
||||
| `theta-env/docs/images/jump-dashboard.png` | same as Jump-host dashboard above |
|
||||
|
||||
## 5. Where to save them
|
||||
|
||||
Only update the **top-level active clones** —
|
||||
`/home/william/dev/theta42/{sso-manager-node,proxy,jump-host,theta-env}` (all
|
||||
on `master`). The copies nested under `theta-env/sso-manager-node`,
|
||||
`theta-env/proxy`, `theta-env/jump-host` are git submodules pinned to a
|
||||
release tag (`HEAD detached at vX.Y.Z`) — those update automatically the next
|
||||
time theta-env's release/tag-bump workflow rolls the submodule pointer
|
||||
forward, not by hand-editing the pinned checkout.
|
||||
|
||||
```sh
|
||||
convert screenshot.jpg /home/william/dev/theta42/<repo>/docs/images/<name>.png
|
||||
```
|
||||
|
||||
(`convert` from ImageMagick — the browser tool saves JPEGs, but the repos
|
||||
track PNGs.)
|
||||
|
||||
Commit each repo separately, same as any other change to that component.
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
layout: default
|
||||
title: Secrets (OpenBao)
|
||||
description: theta-suite's central secrets architecture — OpenBao as the single store for all app, per-user, and external-app secrets, with scoped tokens and policies.
|
||||
---
|
||||
|
||||
# Secrets — OpenBao as the central store
|
||||
|
||||
theta-suite keeps **every secret in one place: [OpenBao](https://openbao.org/)**
|
||||
(a Vault-community fork), running on the `theta-net` docker network at
|
||||
`http://openbao:8200`. The SSO Manager acts as management and abstraction point
|
||||
for secrets management. Via the directory, secrets can be set, cycled, revoked
|
||||
or inherited. **You are not meant to interact with opanBoa directly.**
|
||||
|
||||
## Policies, token role, and tokens
|
||||
|
||||
`setup.sh` creates the ACL policies and mints the per-app tokens
|
||||
(idempotently — re-running keeps existing tokens and re-mints only expired
|
||||
ones). The root token stays in `.env` for setup/maintenance **only** and is
|
||||
never passed to a service container.
|
||||
|
||||
| Policy | Capabilities | Held by |
|
||||
|---|---|---|
|
||||
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`, `secret/agent/*`, `secret/data/resources/*`, `secret/metadata/resources/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
|
||||
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
|
||||
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
|
||||
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_VAULT_TOKEN`) |
|
||||
| `user-<uid>` | read/write `secret/users/<uid>/*` | per-user tokens (minted lazily by the broker) |
|
||||
| `app-<name>` | read/write `secret/apps/<name>/*` | per-external-app tokens (minted by an admin) |
|
||||
|
||||
## Resource Secrets & Zero-View Security Model
|
||||
|
||||
Directory resources (Services, Hosts, Containers, Sites) manage their application secrets at `secret/data/resources/<resource_slug>/conf` in OpenBao KV-v2.
|
||||
|
||||
### 1. Zero-View Security Model
|
||||
* **API Metadata Only**: `GET /api/directory-admin/resources/:id/secrets` returns
|
||||
key names and metadata (`hasValue: true`, `isInherited: true`, `parentSlug`),
|
||||
but **NEVER returns raw secret values**.
|
||||
* **Browser Isolation**: Secret values are never exposed in HTML DOM templates,
|
||||
JSON admin APIs, or browser dev tools.
|
||||
* **Agent-Exclusive Delivery**: Raw secret values are fetched exclusively over
|
||||
TLS by authenticated `theta-agent` instances using machine authorization tokens
|
||||
(`POST /api/v1/agent/secrets`).
|
||||
|
||||
### 2. Multi-Level Hierarchy Secret Inheritance
|
||||
Resources inherit secrets across any level of the directory hierarchy
|
||||
(`Services / Apps → Hosts / Nodes → Global Sites`):
|
||||
* An inherited secret reference is stored as `INHERIT:<parent_slug>:<parent_key>`
|
||||
(or `INHERIT:<key>`).
|
||||
* When requested by `theta-agent`, SSO Manager resolves the inheritance chain
|
||||
dynamically, fetching the final secret value from the parent Site or Host's
|
||||
OpenBao store.
|
||||
|
||||
### 3. Key Validation & Generator
|
||||
* **Key Format**: Secret keys are strictly validated against `^[A-Za-z0-9_]+$`
|
||||
(Standard Environment Variable format, e.g. `DB_PASSWORD`).
|
||||
* **Cryptographic Generator**: The UI includes a client-side cryptographic
|
||||
secret generator (`window.crypto.getRandomValues`) with length choices from 8 to
|
||||
128 characters. Populating the input field displays an inline security warning
|
||||
notifying operators to save immediately before values are hidden.
|
||||
|
||||
## On-Demand CLI Secret Delivery (`theta-agent get-secret`)
|
||||
|
||||
`theta-agent` delivers secrets on demand directly to local processes, shell
|
||||
scripts, Systemd services, and Docker containers without writing plaintext
|
||||
secret files to disk.
|
||||
|
||||
```bash
|
||||
# Fetch single raw secret value (stdout, no trailing newline):
|
||||
theta-agent get-secret DB_PASSWORD
|
||||
|
||||
# Assign directly to shell environment variables:
|
||||
export DB_PASSWORD=$(theta-agent get-secret DB_PASSWORD)
|
||||
|
||||
# Export all host/resource secrets for Systemd EnvironmentFile:
|
||||
theta-agent get-secrets --env
|
||||
|
||||
# Format all secrets as JSON for automation scripts:
|
||||
theta-agent get-secrets --json
|
||||
```
|
||||
|
||||
**Token roles** — three, all orphan + renewable:
|
||||
|
||||
- `sso-broker` — `allowed_policies=sso-admin`, `allowed_policies_glob=user-*,app-*`,
|
||||
`token_period=24h`. The SSO mints per-user and per-admin tokens *through*
|
||||
this role at runtime, so it never needs the root token to issue scoped
|
||||
access. The 24h period is fine here because the broker re-mints these from
|
||||
its Redis cache transparently.
|
||||
- `sso-app` — `allowed_policies_glob=app-*`, `token_period=768h`. External-app
|
||||
tokens minted from the vault UI's Apps tab go through this role: they are
|
||||
long-lived credentials, so they get a monthly period instead of a daily one.
|
||||
- `theta-svc` — `allowed_policies=sso-broker,proxy,jump-host`,
|
||||
`token_period=768h`. The services' own tokens (below).
|
||||
|
||||
### Token lifecycle — nothing expires by surprise
|
||||
|
||||
Periodic tokens never hit a max TTL, but they die if nothing renews them
|
||||
inside a period window. Renewal is automated at every layer:
|
||||
|
||||
- **Service tokens** (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
|
||||
`JUMP_VAULT_TOKEN`, minted via `theta-svc`, stored in `./.env`): the
|
||||
`bao-renewer` sidecar (docker-compose) renews all three every 12 hours, and
|
||||
every `setup.sh` re-run renews them too. A valid-but-non-periodic token from
|
||||
an older install is detected, revoked, and re-minted as periodic on the next
|
||||
`setup.sh` run.
|
||||
- **External-app tokens** (minted in the SSO vault UI): the SSO stores each
|
||||
token's **accessor** (which can renew/revoke but not authenticate) and
|
||||
renews it every 6 hours and at boot — a downstream app's credential stays
|
||||
valid as long as the SSO is running, with no renewal code in the downstream
|
||||
app. Re-minting an app's token revokes the previous one via its accessor, so
|
||||
exactly one credential per app is ever live.
|
||||
- **Per-user / admin tokens**: 24h TTL by design; the broker re-mints them
|
||||
transparently, so there is nothing to renew.
|
||||
|
||||
Worst case (the whole stack was down for >32 days): re-run `./setup.sh` — it
|
||||
re-mints anything that lapsed; external-app tokens are re-minted from the
|
||||
Apps tab (the app's policy and stored secrets are kept).
|
||||
|
||||
## Seeding
|
||||
|
||||
`setup.sh` seeds, on first run only (skipped if the path already exists):
|
||||
|
||||
- `secret/sso-manager/conf` — from `./config/sso-secrets.js` (operator-set
|
||||
LDAP/SMTP/`jwtSecret`; the SSO has no bootstrap-generated creds, so the file
|
||||
is the complete source of truth).
|
||||
- `secret/proxy/conf` — from `./config/proxy-secrets.js` (placeholder OAuth
|
||||
creds at this point).
|
||||
- `secret/jump-host/conf` — from `./config/jump-secrets.js` after the bootstrap
|
||||
writes it.
|
||||
|
||||
The **bootstrap** (`bootstrap/bootstrap.js`) then generates the real OAuth
|
||||
client credentials and writes the *complete* `proxy-secrets.js` and
|
||||
`jump-secrets.js` objects into `secret/proxy/conf` and `secret/jump-host/conf`
|
||||
(POST, replacing the placeholder seed). After the first run, OpenBao is
|
||||
authoritative; the `./config/*-secrets.js` files are operator-edit seed
|
||||
artifacts and the fail-soft fallback.
|
||||
|
||||
## End-user personal secrets
|
||||
|
||||
Every logged-in user has a personal namespace `secret/users/<uid>/*`, reached
|
||||
through the SSO UI at **Vault → My Secrets**. The SSO mints a `user-<uid>`
|
||||
token on first access (cached in Redis for the token's lifetime) and proxies
|
||||
`/api/vault` to OpenBao with **that** token injected server-side — the
|
||||
client's SSO session token never reaches OpenBao.
|
||||
|
||||
- **Non-admins** see only their own namespace; the UI fixes the path prefix
|
||||
to `users/<uid>/`. They can list, read, write, and delete secrets there.
|
||||
- **Admins** (`app_sso_admin` / `app_super_admin`) get free-form access across
|
||||
all of `secret/` plus an **Apps** tab (see below).
|
||||
|
||||
Scoping is enforced at **two** layers: the SSO's `scopeGuard` rejects any path
|
||||
outside the subject's prefix with a 403 (defense-in-depth), and the token's
|
||||
own OpenBao policy enforces the same at the API layer.
|
||||
|
||||
## External apps
|
||||
|
||||
An external (non-theta42) app gets scoped access to its own namespace,
|
||||
`secret/apps/<name>/*`, via a token an admin mints once from the SSO UI's
|
||||
**Vault → Apps** tab. The token is shown **once** (copy it immediately; it is
|
||||
not stored retrievably) and confined by an `app-<name>` policy.
|
||||
|
||||
Convention:
|
||||
|
||||
- `secret/apps/<name>/conf` for config-style secrets, `secret/apps/<name>/*`
|
||||
for arbitrary keys.
|
||||
- The app authenticates with the header `X-Vault-Token: <minted token>`
|
||||
against `http://<openbao-host>:8200/v1/secret/data/apps/<name>/...`.
|
||||
|
||||
Non-Node consumers (curl):
|
||||
|
||||
```bash
|
||||
VAULT_ADDR=http://openbao:8200 # or your external-facing openbao address
|
||||
# Write
|
||||
curl -X POST "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
|
||||
-H "X-Vault-Token: <token>" -H "Content-Type: application/json" \
|
||||
-d '{"data":{"db_password":"..."}}'
|
||||
# Read
|
||||
curl -s "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
|
||||
-H "X-Vault-Token: <token>" | jq .data.data
|
||||
```
|
||||
|
||||
Node consumers can use [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
|
||||
directly:
|
||||
|
||||
```js
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-service/conf
|
||||
await baoConf.set('apps/my-service/conf', { db_password: '...' });
|
||||
```
|
||||
|
||||
## The theta-agent signing key
|
||||
|
||||
The SSO signs high-risk theta-agent commands (`reboot`, `configure_ldap`,
|
||||
`arbitrary_bash`, …) with an Ed25519 key stored at
|
||||
`secret/agent/signing-key`. Agents pin the matching public key in their
|
||||
`agent.yml`, so the key **must** be stable: it used to be generated in memory at
|
||||
process start, which meant it changed on every restart and no agent could
|
||||
meaningfully verify anything.
|
||||
|
||||
If the SSO cannot read or write that path it refuses to send high-risk commands
|
||||
rather than signing with a key no agent has seen — so an upgraded stack that has
|
||||
not re-run `./setup.sh` (and therefore lacks `secret/agent/*` in the
|
||||
`sso-broker` policy) will report `signingAvailable: false` on
|
||||
`GET /api/agent/nodes` and reject those commands with a clear error.
|
||||
|
||||
## Plugin secrets
|
||||
|
||||
The SSO Manager's plugin system (configurable plugin instances you create,
|
||||
edit, load/unload, and run from the **Plugins** page) stores each instance's
|
||||
secrets in its own OpenBao namespace, `secret/plugins/<instance-id>/conf`,
|
||||
rather than in the static `sso-secrets.js` `discovery.plugins` block. The
|
||||
SSO reads and writes these server-side through the `sso-broker` token (the
|
||||
plugin runs in-process as a BullMQ worker, so it needs no token of its own),
|
||||
and the admin UI only ever sees masked (`********`) values.
|
||||
|
||||
- A **plugin type** is a module under `nodejs/plugins/<category>/<type>.js`
|
||||
exporting a manifest (`configSchema` declares which fields are `secret`).
|
||||
- A **plugin instance** is a configured, loadable/unloadable copy of a type,
|
||||
tracked in the `PluginInstance` table; you can have multiple instances of the
|
||||
same type (e.g. two Proxmox endpoints with their own tokens).
|
||||
- Non-secret config lives in the DB row; only the `secret:true` field values
|
||||
live in `secret/plugins/<instance-id>/conf`.
|
||||
|
||||
Deleting an instance removes both the DB row and its `secret/plugins/<id>/*`
|
||||
namespace. Legacy `discovery.plugins` entries in `sso-secrets.js` are migrated
|
||||
to instances automatically on the first boot of SSO Manager ≥ v1.17.0 (the
|
||||
secret fields are copied into OpenBao at that point). See the SSO Manager
|
||||
[plugins docs](https://theta42.github.io/sso-manager-node/plugins.html) for the
|
||||
UI/API reference.
|
||||
|
||||
## Operator rotation
|
||||
|
||||
If a secret is exposed (or just on a routine schedule), rotate it at the
|
||||
**provider** first (the LDAP server, the SMTP host, the OAuth `jwtSecret`,
|
||||
etc.), then update OpenBao:
|
||||
|
||||
```bash
|
||||
# Read the current sso-manager conf
|
||||
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf
|
||||
# Write a new value (KV-v2 POST replaces the data; merge carefully)
|
||||
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf \
|
||||
ldap.bindPassword='<new>' smtp.password='<new>' oauth.jwtSecret='<new>'
|
||||
```
|
||||
|
||||
Then restart the affected app so `bao-conf.init()` re-reads it
|
||||
(`docker compose restart sso-manager`). Call-time readers pick up the change
|
||||
on next read; require-time captures (OIDC `clientSecret`) need the restart.
|
||||
|
||||
> The SSO admin **Configuration** UI (`/api/conf`) writes `secret/sso-manager/conf`
|
||||
> and updates the live conf immediately, so SMTP/discovery/oauth edits made
|
||||
> there don't need a manual `bao kv put`.
|
||||
|
||||
## Backups
|
||||
|
||||
The OpenBao data volume `openbao-data` holds every secret. Back it up with the
|
||||
rest of the stack (see the README's *Backups and restore* section). The
|
||||
`./config/*-secrets.js` files are **not** a complete secret backup once OpenBao
|
||||
is authoritative — they're the first-run seed and the fallback. A full disaster
|
||||
recovery restores both the `openbao-data` volume (the authoritative store) and
|
||||
`./config/` (the seed/fallback), then runs `./setup.sh` to unseal OpenBao and
|
||||
re-mint the per-app tokens.
|
||||
|
||||
## What's not in scope yet
|
||||
|
||||
- **Renewal automation** — per-app/user tokens use OpenBao's default TTL and
|
||||
are re-minted by `setup.sh` on expiry; a periodic renewal worker is a
|
||||
follow-up.
|
||||
- **History scrubbing** — if a secret was committed to git, rotating it is the
|
||||
fix; scrubbing it from git history (BFG / `git filter-repo`) is a separate,
|
||||
git-destructive operation you can opt into.
|
||||
- **Per-app secrets beyond boot config** (e.g. the proxy's DNS-provider creds,
|
||||
the jump host's per-user LDAP SSH keys) moving into OpenBao — only the
|
||||
boot-critical `*-secrets.js` contents moved in this phase. (Plugin instance
|
||||
secrets *are* in OpenBao, at `secret/plugins/<id>/conf` — see above.)
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
|
||||
|
||||
{% seo title=false %}
|
||||
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid px-3">
|
||||
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
|
||||
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
|
||||
{{ site.title }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navMain">
|
||||
<ul class="navbar-nav">
|
||||
{% for item in site.nav %}
|
||||
<li class="nav-item">
|
||||
{% if item.page %}
|
||||
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
|
||||
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-grow-1" style="margin-top: 4.5rem;">
|
||||
<div class="container-fluid py-4 py-md-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-body p-4 p-md-5 site-content">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-3 bg-dark text-light mt-auto">
|
||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<a href="https://theta42.com" target="_blank" rel="noopener">
|
||||
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
|
||||
</a>
|
||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
||||
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-3">
|
||||
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-brands fa-github"></i> GitHub
|
||||
</a>
|
||||
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
|
||||
<i class="fa-solid fa-list"></i> Changelog
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
layout: default
|
||||
title: Discovery Agents
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Discovery Agents
|
||||
|
||||
Theta Directory 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#C59341" />
|
||||
<stop offset="20%" stop-color="#E4B869" />
|
||||
<stop offset="40%" stop-color="#FBF0B9" />
|
||||
<stop offset="60%" stop-color="#DFB260" />
|
||||
<stop offset="80%" stop-color="#BC8837" />
|
||||
<stop offset="100%" stop-color="#A36F28" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="40%" stop-color="#F5E3B5" />
|
||||
<stop offset="70%" stop-color="#D4A343" />
|
||||
<stop offset="100%" stop-color="#8A5A16" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#drop-shadow)">
|
||||
<g fill="url(#gold-grad)">
|
||||
<path d="M 200,40
|
||||
C 290,40 350,110 350,200
|
||||
C 350,290 290,360 200,360
|
||||
C 110,360 50,290 50,200
|
||||
C 50,110 110,40 200,40 Z
|
||||
M 200,75
|
||||
C 130,75 88,130 88,200
|
||||
C 88,270 130,325 200,325
|
||||
C 270,325 312,270 312,200
|
||||
C 312,130 270,75 200,75 Z"
|
||||
fill-rule="evenodd" />
|
||||
|
||||
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
|
||||
|
||||
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
|
||||
</g>
|
||||
|
||||
<text x="200" y="222"
|
||||
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-size="78"
|
||||
font-weight="900"
|
||||
fill="url(#text-grad)"
|
||||
text-anchor="middle"
|
||||
letter-spacing="-2">42</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,125 @@
|
||||
---
|
||||
layout: default
|
||||
title: Accounts, Groups & Managers
|
||||
description: A plain-language guide to users, service accounts, personal groups, and managers in Theta Directory.
|
||||
---
|
||||
|
||||
# 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 Theta Directory 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 `<uid>`'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)
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
layout: default
|
||||
title: API Tokens
|
||||
description: A plain-language guide to personal access tokens in Theta Directory.
|
||||
---
|
||||
|
||||
# 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)
|
||||
@@ -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 Theta Directory.
|
||||
---
|
||||
|
||||
# Connecting Apps (Single Sign-On)
|
||||
|
||||
This page explains, in plain language, what happens when you "connect" an
|
||||
app to Theta Directory 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 Theta Directory 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 Theta Directory to authenticate people on its
|
||||
behalf.
|
||||
|
||||
**Treat the Client Secret like a password** — anyone who has it can
|
||||
impersonate that app when talking to Theta Directory. 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 a Theta Directory 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
|
||||
Theta Directory 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)
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
layout: default
|
||||
title: Configuration
|
||||
description: Theta Directory'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/<NODE_ENV>.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` / `<NODE_ENV>.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)
|
||||
@@ -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
|
||||
|
||||
Theta Directory 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, Theta Directory 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), Theta Directory will automatically provision two LDAP groups in your directory to govern access to that resource:
|
||||
|
||||
1. `<slug>_access` (Member level access)
|
||||
2. `<slug>_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 <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
|
||||
- **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.
|
||||
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
||||
|
||||
## Slug conventions
|
||||
|
||||
Slugs are the stable identifiers automation keys off, so the tooling around Theta Directory follows a shared convention:
|
||||
|
||||
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
||||
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
||||
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
|
||||
|
||||
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_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_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||
- the **services** it composes — Theta Directory, 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_<hostname>` 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=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_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
|
||||
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 320 KiB |
@@ -0,0 +1,52 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: Theta Directory — the OpenID Connect provider, bundled OpenLDAP directory, and resource inventory at the core of theta-suite. One login for your modern apps, one LDAP directory for the rest, no phone-home.
|
||||
---
|
||||
|
||||
# Theta Directory
|
||||
|
||||
The identity and directory component of [theta-suite](../): an **OpenID
|
||||
Connect provider**, a bundled **OpenLDAP directory**, and a **resource
|
||||
inventory & IAM engine**, all behind one web console.
|
||||
|
||||
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
|
||||
— plus a graph of every site, host, and service you run, with auto-provisioned
|
||||
access groups. Everything runs on your own hardware; no phone-home, no hosted
|
||||
control plane, no per-user pricing.
|
||||
|
||||
Theta Directory is deployed as part of theta-suite, alongside
|
||||
[Proxy](../proxy/) and [Jump Host](../jump-host/) — it isn't installed or run
|
||||
on its own. See the [Quickstart](../quickstart.html) to stand up the whole
|
||||
stack with one command.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Overview dashboard" width="49%"></a>
|
||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
|
||||
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Agent capabilities & metrics" width="49%"></a>
|
||||
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Agent install with join key" width="49%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
## 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.
|
||||
- **[Multi-Site](multi-site.html)** — one master site, any number of read-only spokes that join with a single key and stay live-synced, with god_admin-gated promotion if the master goes down for good.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites (a different, lower-level mechanism — see [Multi-Site](multi-site.html) for how the two compare).
|
||||
- **[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-suite's agents and discovery plugins. Drives directory-aware tools like the [SSH jump host](../jump-host/).
|
||||
- **Subtype metrics & lifecycle drivers** — telemetry, log streaming, and remote control for resources tagged with a `subType` (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`, …).
|
||||
- **OpenBao-backed secrets** — per-resource and per-user secrets with explicit upward inheritance (`Resource → Host → Cluster → Site`).
|
||||
@@ -0,0 +1,432 @@
|
||||
---
|
||||
layout: default
|
||||
title: LDAP
|
||||
description: Theta Directory'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).
|
||||
|
||||
Theta Directory 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=<uid>,ou=people,<base>` 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=<name>,ou=groups,<base>` (`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 `<slug>_admin`
|
||||
group, and each `<slug>_admin` into its `<slug>_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=<uid>,ou=groups,<base>`, `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 `<uid>`'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 Theta Directory (for
|
||||
example, the bundled `theta-suite` 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://<your-sso-host>:636` (preferred), or `ldap://<host>:389` + StartTLS |
|
||||
| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,<base>` (see above) |
|
||||
| Bind password | that service account's password |
|
||||
| User search base | `ou=people,<base>` |
|
||||
| User search filter | `(objectClass=posixAccount)` |
|
||||
| Username attribute | `uid` |
|
||||
| Email attribute | `mail` |
|
||||
| Group search base | `ou=groups,<base>` |
|
||||
| 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: <service-account-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,<base>
|
||||
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,<base>`.
|
||||
- **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 <admin-password>` 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-<date>.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)
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
layout: default
|
||||
title: Multi-Site (Master/Spoke Join)
|
||||
---
|
||||
|
||||
# Multi-Site (Master/Spoke Join)
|
||||
|
||||
If you run more than one physical site, Theta Directory can run one site as
|
||||
the **master** (single write authority for the shared catalog) and any
|
||||
number of **spokes** — read-only replicas that stay in sync automatically and
|
||||
run local authentication with zero WAN dependency.
|
||||
|
||||
This is a different, higher-level mechanism than [raw LDAP N-way
|
||||
replication](replication.html) — see [How this relates to LDAP
|
||||
replication](#how-this-relates-to-ldap-replication) below if you're deciding
|
||||
between the two.
|
||||
|
||||
## Why and when to use this
|
||||
|
||||
- **Zero-touch spoke setup.** One join key, one URL, and a spoke adopts the
|
||||
whole directory (users, groups, resource catalog) in one step — no manual
|
||||
`syncrepl` configuration.
|
||||
- **Single write authority, no split-brain.** Only the master accepts
|
||||
directory writes. A spoke that loses WAN connectivity keeps working for
|
||||
local reads/auth and unconditionally stays read-only — it never silently
|
||||
promotes itself. Changing which site is master always requires an explicit,
|
||||
authenticated action by a `god_admin`.
|
||||
- **Stays in sync, not just a one-time copy.** Once joined, a spoke keeps
|
||||
receiving live updates whenever the master's catalog changes — you don't
|
||||
re-run the join to pick up new hosts/apps/users.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **On the master**, an admin mints a **site join key** (Directory → the
|
||||
Master Site modal → **Site Join Keys** → Mint key). It's shown once,
|
||||
stored hashed, and revocable.
|
||||
2. **On the spoke** (must be a fresh install — no users beyond the bootstrap
|
||||
admin, no enrolled agents), either:
|
||||
- Paste the master's URL and the join key into the Master Site modal's
|
||||
**Join an Existing Site** form, or
|
||||
- Set `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY` before
|
||||
the first `./setup.sh` run -- either in `setup.env` (which has every
|
||||
option), or in a dedicated `spoke.env` (`cp spoke.env.example spoke.env`)
|
||||
if you'd rather keep join-a-cluster config separate from the rest of the
|
||||
stack's setup. Both are read; `spoke.env`'s values win on a conflict.
|
||||
No public IP on this site at all? `spoke.env.example` also covers the
|
||||
no-inbound relay vars (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`).
|
||||
3. The spoke pulls the master's full export (LDAP tree, resource catalog,
|
||||
agent-signing key) and adopts it, then registers its own reachable URL
|
||||
with the master so it can receive live updates going forward.
|
||||
4. From then on, every change to the master's catalog pushes to every
|
||||
registered spoke automatically. A spoke's own directory-write requests are
|
||||
rejected with a `403` pointing at the master — writes always go there.
|
||||
|
||||
### Promoting a spoke to master
|
||||
|
||||
If the master site goes down for good (or you're relocating write
|
||||
authority), a `god_admin` can promote any spoke from its own Master Site
|
||||
modal. Promotion is one coordinated action: it demotes the previous master as
|
||||
part of the same request (best-effort — an unreachable old master never
|
||||
blocks the promotion, since that's exactly the scenario this exists for), and
|
||||
every other spoke gets pointed at the new master automatically.
|
||||
|
||||
## What replicates
|
||||
|
||||
| Data | How |
|
||||
|---|---|
|
||||
| LDAP (users, groups) | Full export on join; live push on every master change |
|
||||
| Resource catalog (hosts, apps, sites) | Same |
|
||||
| Agent-signing key | Same — every site can validly sign a command for any agent enrolled at *any* site |
|
||||
|
||||
The agent-signing key being identical everywhere is a deliberate tradeoff for
|
||||
small, trusted deployments (a handful of sites, not hundreds) — it means
|
||||
compromising the least-secured spoke has the same agent-command blast radius
|
||||
as compromising the master. If that tradeoff doesn't fit your deployment,
|
||||
don't rely on this mechanism as-is.
|
||||
|
||||
Secrets *beyond* the agent-signing key (LDAP admin password, JWT secret, and
|
||||
so on) are **not** currently synced — each site still generates its own.
|
||||
|
||||
## Requirements and current limits
|
||||
|
||||
- Both sites need a network path to each other's HTTP(S) API — the master to
|
||||
pull an export from, the spoke to push replication updates back to. A site
|
||||
with no inbound path at all (e.g. behind CGNAT) can't join yet on its own;
|
||||
a relay mechanism for that case is designed but not automated (see the
|
||||
[architecture spec](https://github.com/theta42/theta-suite/blob/master/docs/MULTI_SITE_SPEC.md)
|
||||
for the current status).
|
||||
- Joining only ever happens on a **fresh install**. There's no way to merge
|
||||
an already-populated directory into a master's — re-provision the host
|
||||
first.
|
||||
|
||||
## How this relates to LDAP replication
|
||||
|
||||
[N-way LDAP replication](replication.html) is a *lower-level*, different
|
||||
mechanism: every site runs a fully independent, fully writable `slapd`, wired
|
||||
together with raw `syncrepl` environment variables, and there's no concept of
|
||||
a master or a managed join. It predates this feature and is still there for
|
||||
deployments that specifically want every site independently writable.
|
||||
|
||||
Multi-site join (this page) is the opposite design: one write authority, a
|
||||
managed onboarding flow, and automatic ongoing sync — closer to what most
|
||||
"add a second office" or "add a home-lab spoke" setups actually want. **Don't
|
||||
combine the two** — pick one per deployment.
|
||||
|
||||
## See also
|
||||
|
||||
- Full architecture and current implementation status:
|
||||
[`MULTI_SITE_SPEC.md`](https://github.com/theta42/theta-suite/blob/master/docs/MULTI_SITE_SPEC.md)
|
||||
in the `theta-suite` repo.
|
||||
- Endpoint-level detail: [`docs/site-join.md`](https://github.com/theta42/theta-directory/blob/master/docs/site-join.md)
|
||||
in the `theta-directory` repo.
|
||||
- Site-to-site networking (WireGuard mesh between gateways, independent of
|
||||
directory sync): [Theta Gateway → Mesh](../jump-host/mesh.html).
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
layout: default
|
||||
title: OAuth / OIDC
|
||||
description: Theta Directory'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).
|
||||
|
||||
Theta Directory 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://<sso-host>/.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.
|
||||
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
layout: default
|
||||
title: Geo-Location Scaling (Replication)
|
||||
---
|
||||
|
||||
# Geo-Location Scaling (Replication)
|
||||
|
||||
Theta Directory bundles its own 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.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
layout: default
|
||||
title: Secrets Vault
|
||||
nav_order: 6
|
||||
---
|
||||
|
||||
# Secrets Vault
|
||||
|
||||
Theta Directory 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 Theta Directory 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 Theta Directory Vault can be accessed in two ways:
|
||||
|
||||
1. **Via the Theta Directory 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 Theta Directory 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, Theta Directory 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/<instance-id>/conf` (configured, loaded/unloaded, and run from
|
||||
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
|
||||
in-process, so Theta Directory 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.
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Standalone
|
||||
description: Running the SSO Manager or the proxy on their own, without theta-env's orchestration.
|
||||
---
|
||||
|
||||
# Running each project standalone
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
theta-env composes the two projects but doesn't fork them — both work on their
|
||||
own. The submodules in this repo are normal clones; you can also clone them
|
||||
directly from GitHub.
|
||||
|
||||
---
|
||||
|
||||
## 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://<host>: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://<host>/`
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## Mixing and matching
|
||||
|
||||
theta-env isn't required to use the two together — 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`.
|
||||
|
||||
theta-env just automates those four steps with `./setup.sh`. If you prefer to
|
||||
do them by hand (or want the two on separate hosts), follow the standalone
|
||||
guides above.
|
||||
|
||||
[← Back to Home](index.html)
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# LDAP Migration Script for theta42
|
||||
#
|
||||
# Migrates an existing OpenLDAP server to the theta42 stack.
|
||||
# Exports data from source, transforms as needed, imports into theta42.
|
||||
#
|
||||
# Usage:
|
||||
# ./migrate-ldap.sh --source-host <ldap-uri> --source-bind-dn <dn> --source-bind-pass <pass> --target-domain <domain>
|
||||
#
|
||||
# Example:
|
||||
# ./migrate-ldap.sh --source-host ldap://192.168.1.10:389 --source-bind-dn "cn=admin,dc=example,dc=com" --source-bind-pass "secret" --target-domain "example.com"
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
SOURCE_HOST=""
|
||||
SOURCE_BIND_DN=""
|
||||
SOURCE_BIND_PASS=""
|
||||
TARGET_DOMAIN=""
|
||||
BASE_DN=""
|
||||
EXPORT_DIR="./ldap-migration-$(date +%Y%m%d-%H%M%S)"
|
||||
THETA_ENV_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { printf "${BLUE}[migrate]${NC} %s\n" "$*"; }
|
||||
warn() { printf "${YELLOW}[migrate]${NC} %s\n" "$*" >&2; }
|
||||
error() { printf "${RED}[migrate]${NC} %s\n" "$*" >&2; }
|
||||
success() { printf "${GREEN}[migrate]${NC} %s\n" "$*" >&2; }
|
||||
die() { error "$*"; exit 1; }
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-host)
|
||||
SOURCE_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--source-bind-dn)
|
||||
SOURCE_BIND_DN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--source-bind-pass)
|
||||
SOURCE_BIND_PASS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target-domain)
|
||||
TARGET_DOMAIN="$2"
|
||||
shift 2
|
||||
;;
|
||||
--export-dir)
|
||||
EXPORT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
LDAP Migration Script for theta42
|
||||
|
||||
Usage: $0 --source-host <uri> --source-bind-dn <dn> --source-bind-pass <pass> --target-domain <domain>
|
||||
|
||||
Options:
|
||||
--source-host Source LDAP URI (e.g., ldap://192.168.1.10:389 or ldaps://ldap.example.com:636)
|
||||
--source-bind-dn Bind DN for source LDAP (e.g., cn=admin,dc=example,dc=com)
|
||||
--source-bind-pass Bind password for source LDAP
|
||||
--target-domain Target domain for theta42 (e.g., example.com)
|
||||
--export-dir Directory for exports (default: ./ldap-migration-<timestamp>)
|
||||
--help Show this help message
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Validation ────────────────────────────────────────────────────────────────
|
||||
[[ -n "$SOURCE_HOST" ]] || die "Missing --source-host"
|
||||
[[ -n "$SOURCE_BIND_DN" ]] || die "Missing --source-bind-dn"
|
||||
[[ -n "$SOURCE_BIND_PASS" ]] || die "Missing --source-bind-pass"
|
||||
[[ -n "$TARGET_DOMAIN" ]] || die "Missing --target-domain"
|
||||
|
||||
# Derive base DN from domain (e.g., example.com -> dc=example,dc=com)
|
||||
BASE_DN="$(echo "$TARGET_DOMAIN" | sed 's/\./,dc=/g; s/^/dc=/')"
|
||||
|
||||
info "Migration configuration:"
|
||||
info " Source host: $SOURCE_HOST"
|
||||
info " Source bind DN: $SOURCE_BIND_DN"
|
||||
info " Target domain: $TARGET_DOMAIN"
|
||||
info " Target base DN: $BASE_DN"
|
||||
info " Export dir: $EXPORT_DIR"
|
||||
|
||||
# ── Prerequisites ─────────────────────────────────────────────────────────────
|
||||
command -v ldapsearch >/dev/null 2>&1 || die "ldapsearch not found. Install ldap-utils."
|
||||
command -v slapcat >/dev/null 2>&1 || die "slapcat not found."
|
||||
command -v docker >/dev/null 2>&1 || die "docker not found."
|
||||
command -v docker-compose >/dev/null 2>&1 || command -v docker compose >/dev/null 2>&1 || die "docker compose not found."
|
||||
|
||||
if [[ -d "$EXPORT_DIR" ]]; then
|
||||
warn "Export directory already exists: $EXPORT_DIR"
|
||||
read -p "Overwrite? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
info "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$EXPORT_DIR"
|
||||
|
||||
# ── Phase 1: Export from source LDAP ─────────────────────────────────────────
|
||||
info "Phase 1: Exporting data from source LDAP..."
|
||||
|
||||
# Export each subtree
|
||||
export_subtree() {
|
||||
local base="$1"
|
||||
local outfile="$2"
|
||||
info " Exporting $base -> $outfile"
|
||||
|
||||
# Use ldapsearch with -LLL for LDIF output
|
||||
if ! ldapsearch -x -H "$SOURCE_HOST" -D "$SOURCE_BIND_DN" -w "$SOURCE_BIND_PASS" \
|
||||
-b "$base" -s sub "(objectClass=*)" > "$outfile" 2>/dev/null; then
|
||||
warn " No data or base DN not found: $base"
|
||||
# Create empty file to signal "checked"
|
||||
echo "# No data for $base" > "$outfile"
|
||||
fi
|
||||
}
|
||||
|
||||
# Export standard subtrees
|
||||
export_subtree "ou=people,$BASE_DN" "$EXPORT_DIR/01-people.ldif"
|
||||
export_subtree "ou=groups,$BASE_DN" "$EXPORT_DIR/02-groups.ldif"
|
||||
export_subtree "ou=sudoers,$BASE_DN" "$EXPORT_DIR/03-sudoers.ldif"
|
||||
export_subtree "ou=services,$BASE_DN" "$EXPORT_DIR/04-services.ldif"
|
||||
|
||||
# Also export cn=config for reference (read-only, won't import)
|
||||
info " Exporting cn=config for reference..."
|
||||
ldapsearch -x -H "$SOURCE_HOST" -D "$SOURCE_BIND_DN" -w "$SOURCE_BIND_PASS" \
|
||||
-b "cn=config" -s sub "(objectClass=*)" > "$EXPORT_DIR/00-config-reference.ldif" 2>/dev/null || true
|
||||
|
||||
# Count entries
|
||||
for f in "$EXPORT_DIR"/*.ldif; do
|
||||
count=$(grep -c "^dn:" "$f" 2>/dev/null || echo 0)
|
||||
info " $(basename "$f"): $count entries"
|
||||
done
|
||||
|
||||
success "Export complete: $EXPORT_DIR"
|
||||
|
||||
# ── Phase 2: Transform LDIF ──────────────────────────────────────────────────
|
||||
info "Phase 2: Transforming LDIF for theta42 compatibility..."
|
||||
|
||||
# Create transformation script
|
||||
cat > "$EXPORT_DIR/transform.sh" <<'TRANSFORM_SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
# Transform exported LDIF for theta42 compatibility
|
||||
|
||||
INPUT="$1"
|
||||
OUTPUT="$2"
|
||||
BASE_DN="$3"
|
||||
|
||||
# theta42 requires certain objectClasses and attributes
|
||||
# This script:
|
||||
# 1. Ensures posixAccount has uidNumber, gidNumber, homeDirectory, loginShell
|
||||
# 2. Ensures groupOfNames has at least one member
|
||||
# 3. Adds ldapPublicKey objectClass where sshPublicKey exists
|
||||
# 4. Normalizes password hash formats if needed
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
echo "$line"
|
||||
done < "$INPUT" > "$OUTPUT"
|
||||
|
||||
echo "Transform complete: $OUTPUT"
|
||||
TRANSFORM_SCRIPT
|
||||
chmod +x "$EXPORT_DIR/transform.sh"
|
||||
|
||||
# For now, we'll do a direct import. The transformation is minimal for most setups.
|
||||
# If you have custom schemas, you may need to edit the LDIF manually.
|
||||
|
||||
# ── Phase 3: Prepare theta42 LDAP ────────────────────────────────────────────
|
||||
info "Phase 3: Preparing theta42 LDAP..."
|
||||
|
||||
# Stop theta42 stack
|
||||
COMPOSE_CMD=""
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
die "docker compose not found"
|
||||
fi
|
||||
|
||||
info " Stopping sso-manager container..."
|
||||
$COMPOSE_CMD stop sso-manager 2>/dev/null || true
|
||||
|
||||
# Wait for container to stop
|
||||
sleep 3
|
||||
|
||||
# ── Phase 4: Import into theta42 ─────────────────────────────────────────────
|
||||
info "Phase 4: Importing data into theta42 LDAP..."
|
||||
|
||||
# Create import script that runs inside the container
|
||||
cat > "$EXPORT_DIR/import-to-theta42.sh" <<'IMPORT_SCRIPT'
|
||||
#!/bin/bash
|
||||
# Run inside theta42 sso-manager container to import LDIF
|
||||
|
||||
set -e
|
||||
|
||||
EXPORT_DIR="$1"
|
||||
BASE_DN="$2"
|
||||
|
||||
# Stop slapd if running
|
||||
pkill slapd 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Clear existing data (but preserve structure)
|
||||
info "Clearing existing LDAP data..."
|
||||
rm -rf /var/lib/ldap/*
|
||||
rm -rf /var/lib/ldap/db.*
|
||||
|
||||
# Initialize LDAP database with theta42 schema
|
||||
info "Initializing LDAP database..."
|
||||
|
||||
# Create initial LDIF with base structure
|
||||
cat > /tmp/base.ldif <<EOF
|
||||
dn: $BASE_DN
|
||||
objectClass: top
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
dc: $(echo $BASE_DN | sed 's/,dc=.*//; s/dc=//')
|
||||
o: Organization
|
||||
|
||||
dn: ou=people,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: people
|
||||
|
||||
dn: ou=groups,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: groups
|
||||
|
||||
dn: ou=sudoers,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: sudoers
|
||||
|
||||
dn: ou=services,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: services
|
||||
|
||||
dn: cn=admin,$BASE_DN
|
||||
objectClass: organizationalRole
|
||||
cn: admin
|
||||
description: LDAP Administrator
|
||||
|
||||
dn: cn=ldap-admin,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
cn: ldap-admin
|
||||
member: cn=admin,$BASE_DN
|
||||
EOF
|
||||
|
||||
# Import base structure
|
||||
slapadd -c -l /tmp/base.ldif -b "$BASE_DN" 2>/dev/null || true
|
||||
|
||||
# Import user data
|
||||
for f in "$EXPORT_DIR"/*.ldif; do
|
||||
[[ -f "$f" ]] || continue
|
||||
[[ "$(basename "$f")" == "00-config-reference.ldif" ]] && continue
|
||||
|
||||
info "Importing $f..."
|
||||
# Use -c to continue on errors (some entries may already exist)
|
||||
slapadd -c -l "$f" -b "$BASE_DN" 2>/dev/null || warn "Some entries in $f may have failed"
|
||||
done
|
||||
|
||||
# Fix ownership
|
||||
chown -R ldap:ldap /var/lib/ldap
|
||||
|
||||
# Start slapd
|
||||
info "Starting slapd..."
|
||||
exec /usr/sbin/slapd -h "ldap:/// ldaps:///" -u ldap -g ldap
|
||||
|
||||
IMPORT_SCRIPT
|
||||
|
||||
# Copy import script to export dir
|
||||
cp "$EXPORT_DIR/import-to-theta42.sh" "$EXPORT_DIR/"
|
||||
|
||||
# Run the import inside the container
|
||||
info "Running import inside sso-manager container..."
|
||||
|
||||
# First, start a temporary container to do the import
|
||||
$COMPOSE_CMD up -d sso-manager 2>/dev/null || true
|
||||
sleep 5
|
||||
|
||||
# Copy LDIF files into container
|
||||
info "Copying LDIF files to container..."
|
||||
for f in "$EXPORT_DIR"/*.ldif; do
|
||||
[[ -f "$f" ]] || continue
|
||||
docker cp "$f" sso-manager:/tmp/migration/ 2>/dev/null || {
|
||||
docker exec sso-manager mkdir -p /tmp/migration
|
||||
docker cp "$f" sso-manager:/tmp/migration/
|
||||
}
|
||||
done
|
||||
|
||||
# Run import
|
||||
info "Executing import..."
|
||||
docker exec sso-manager bash -c "
|
||||
pkill slapd 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# Clear data
|
||||
rm -rf /var/lib/ldap/*
|
||||
|
||||
# Create base structure
|
||||
slapadd -c -b '$BASE_DN' <<EOF
|
||||
dn: $BASE_DN
|
||||
objectClass: top
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
dc: $(echo $BASE_DN | cut -d',' -f1 | cut -d'=' -f2)
|
||||
o: $TARGET_DOMAIN
|
||||
|
||||
dn: ou=people,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: people
|
||||
|
||||
dn: ou=groups,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: groups
|
||||
|
||||
dn: ou=sudoers,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: sudoers
|
||||
|
||||
dn: ou=services,$BASE_DN
|
||||
objectClass: organizationalUnit
|
||||
ou: services
|
||||
EOF
|
||||
|
||||
# Import user data
|
||||
for f in /tmp/migration/*.ldif; do
|
||||
[[ \"\$(basename \$f)\" == \"00-config-reference.ldif\" ]] && continue
|
||||
[[ -f \"\$f\" ]] || continue
|
||||
echo \"Importing \$f...\"
|
||||
slapadd -c -l \"\$f\" -b '$BASE_DN' 2>/dev/null || echo \"Warning: Some entries in \$f may have failed\"
|
||||
done
|
||||
|
||||
# Fix ownership
|
||||
chown -R ldap:ldap /var/lib/ldap
|
||||
|
||||
echo \"Import complete!\"
|
||||
" || warn "Import had some errors - check output above"
|
||||
|
||||
# ── Phase 5: Create theta42 admin groups ─────────────────────────────────────
|
||||
info "Phase 5: Creating theta42 admin groups..."
|
||||
|
||||
# Create LDIF for theta42-specific groups
|
||||
cat > "$EXPORT_DIR/theta42-groups.ldif" <<EOF
|
||||
# theta42 administrative groups
|
||||
# These groups control access to various features
|
||||
|
||||
# Cross-app super admin - full admin in all apps
|
||||
dn: cn=app_super_admin,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: app_super_admin
|
||||
description: Cross-app super administrators
|
||||
|
||||
# SSO Manager admin
|
||||
dn: cn=app_sso_admin,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: app_sso_admin
|
||||
description: SSO Manager administrators
|
||||
|
||||
# SSO invite - can invite users
|
||||
dn: cn=app_sso_invite,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: app_sso_invite
|
||||
description: Can send invitations
|
||||
|
||||
# OAuth admin - manages OAuth clients
|
||||
dn: cn=app_sso_oauth_admin,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: app_sso_oauth_admin
|
||||
description: OAuth client administrators
|
||||
|
||||
# Service account marker
|
||||
dn: cn=app_sso_service_account,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: app_sso_service_account
|
||||
description: Service accounts (hidden from UI)
|
||||
|
||||
# Jump host admin - audit access only
|
||||
dn: cn=app_jump_admin,ou=groups,$BASE_DN
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: app_jump_admin
|
||||
description: Jump host audit administrators
|
||||
EOF
|
||||
|
||||
# Import the theta42 groups
|
||||
docker exec sso-manager bash -c "
|
||||
slapadd -c -l /tmp/theta42-groups.ldif -b '$BASE_DN' 2>/dev/null || echo \"Groups may already exist\"
|
||||
" <<EOF
|
||||
$(cat "$EXPORT_DIR/theta42-groups.ldif")
|
||||
EOF
|
||||
|
||||
# ── Phase 6: Restart and verify ──────────────────────────────────────────────
|
||||
info "Phase 6: Restarting theta42 stack..."
|
||||
|
||||
$COMPOSE_CMD restart sso-manager
|
||||
sleep 10
|
||||
|
||||
info "Waiting for sso-manager to be healthy..."
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
|
||||
success "sso-manager is healthy!"
|
||||
break
|
||||
fi
|
||||
if (( i == 30 )); then
|
||||
warn "sso-manager did not become healthy in 30s. Check logs with: docker compose logs sso-manager"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify import
|
||||
info "Verifying import..."
|
||||
dn_count=$(docker exec sso-manager ldapsearch -x -H "ldap://localhost" -b "$BASE_DN" -s sub "(objectClass=*)" dn 2>/dev/null | grep -c "^dn:" || echo 0)
|
||||
info "Total entries in LDAP: $dn_count"
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
success "Migration complete!"
|
||||
echo ""
|
||||
info "Summary:"
|
||||
info " - Exported data saved to: $EXPORT_DIR"
|
||||
info " - Base DN: $BASE_DN"
|
||||
info " - Total entries: $dn_count"
|
||||
echo ""
|
||||
info "Next steps:"
|
||||
info " 1. Review the exported LDIF files in $EXPORT_DIR"
|
||||
info " 2. Add users to theta42 admin groups as needed:"
|
||||
info " docker exec sso-manager ldapmodify -x -H ldap://localhost -D 'cn=admin,$BASE_DN' -w <admin-pass>"
|
||||
info " 3. Update your LDAP clients to point to theta42"
|
||||
info " 4. Run ./setup.sh to complete theta42 bootstrap"
|
||||
echo ""
|
||||
warn "IMPORTANT: Update all LDAP clients to use the new theta42 LDAP server!"
|
||||
warn " - SSSD: Update /etc/sssd/sssd.conf ldap_uri"
|
||||
warn " - sudo: Update /etc/sudo-ldap.conf"
|
||||
warn " - Apps: Update LDAP connection strings"
|
||||
@@ -1,5 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# setup.env — first-run setup for the theta-env stack.
|
||||
# setup.env — first-run setup for the theta-suite stack.
|
||||
#
|
||||
# This file is used ONLY on the FIRST run of ./setup.sh, to generate
|
||||
# ./config/sso-secrets.js + ./config/proxy-secrets.js with your domain filled
|
||||
@@ -21,28 +21,111 @@
|
||||
# setup.sh refuses to run without it.
|
||||
CFG_DOMAIN=example.com
|
||||
|
||||
# Site name for the SSO directory — the root node this stack registers itself
|
||||
# under on the Directory page, and the default "Location (Site)" that Linux
|
||||
# hosts joined via ldap-client attach to (parent slug: site_<name>).
|
||||
# Optional — defaults to "local".
|
||||
#CFG_SITE_NAME=local
|
||||
|
||||
# Public hostnames. Optional — default to sso.<domain> / proxy.<domain> derived
|
||||
# from CFG_DOMAIN above. Uncomment and set only if your hostnames differ
|
||||
# (e.g. a different subdomain, or the domain isn't the bare apex):
|
||||
#CFG_SSO_HOST=sso.example.com
|
||||
#CFG_PROXY_HOST=proxy.example.com
|
||||
|
||||
# ── SSH jump host (always installed) ─────────────────────────────────────────
|
||||
# The theta42/jump-host component is installed and started by default — a
|
||||
# public SSH jump host that authenticates users against the directory and
|
||||
# bridges them to downstream hosts (ssh uid_-_target@jump, or an interactive
|
||||
# picker). setup.sh clones/builds the jump-host submodule, the bootstrap mints
|
||||
# its directory API token + writes ./config/jump-secrets.js, and it's registered
|
||||
# in the proxy + directory. See jump-host's README for the LDAP write-ACL note
|
||||
# (the bundled deployment binds as cn=admin).
|
||||
#CFG_JUMP_HOST=jump.example.com # defaults to jump.<domain>
|
||||
#JUMP_SSH_PORT=2222 # host port mapped to the jump host's SSH (never 22 by default)
|
||||
|
||||
# Advanced: override the derived LDAP base DN directly (e.g. to namespace
|
||||
# under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
|
||||
#CFG_BASE_DN=dc=example,dc=com
|
||||
|
||||
# ── Multi-Site: join an existing (master) directory ──────────────────────────
|
||||
# To run THIS deployment as a read-only SPOKE of an existing Theta Directory
|
||||
# instead of seeding a fresh one, set the master's URL and a site join key
|
||||
# (mint one on the master: Directory -> the Master Site modal -> Site Join Keys
|
||||
# -> Mint key). Honored ONLY on a first-run bring-up (before ./config/ exists),
|
||||
# so it can never merge an already-populated directory; re-runs ignore it.
|
||||
# The spoke adopts the master's users/groups/resources and persists its spoke
|
||||
# role in ./config/site.json (isMaster=false, masterUrl, siteSlug). It also
|
||||
# registers itself with the master (using this site's own CFG_SSO_HOST) so
|
||||
# future catalog changes on the master get pushed here live instead of this
|
||||
# being a one-time snapshot -- the master must be able to reach THIS site's
|
||||
# CFG_SSO_HOST for that part to work; if it can't (this site has no inbound
|
||||
# path), the join still succeeds, it just never receives live updates.
|
||||
#
|
||||
# All of these (and the no-inbound relay pair below) also live in their own
|
||||
# spoke.env.example, if you'd rather keep join-a-cluster config in a
|
||||
# dedicated file instead of here -- both are read, spoke.env's values win.
|
||||
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
|
||||
# This site's own public web domain, independent of CFG_DOMAIN above (the
|
||||
# shared LDAP identity namespace, which must be identical across every site).
|
||||
# Optional -- only meaningful for an inbound spoke/standalone site that wants
|
||||
# its own domain rather than sharing the master's.
|
||||
#CFG_PUBLIC_DOMAIN=branch2.example.com
|
||||
|
||||
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
||||
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
||||
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
||||
# manual, out-of-band step on BOTH jump-hosts (mint a mesh join token on the
|
||||
# master's jump-host, paste it into this site's jump-host "Join a mesh" UI
|
||||
# action) that can't run unattended inside this script. Once that's done, set
|
||||
# these two and re-run setup.sh: it discovers this jump-host's assigned mesh
|
||||
# IP (GET /api/mesh/self) and registers it with the master, which then
|
||||
# auto-creates the relay route on its own theta-proxy. Safe to leave set
|
||||
# before meshing -- setup.sh just reports "not meshed yet" and skips until a
|
||||
# later re-run finds the mesh IP.
|
||||
#CFG_SPOKE_NO_INBOUND=true
|
||||
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
||||
|
||||
# Two service-to-service integrations the Directory uses (both reuse each
|
||||
# app's existing self-service API token system -- see MULTI_SITE_SPEC.md's
|
||||
# "service-to-service auth" note -- not a new credential type each):
|
||||
# - No-inbound relay automation (above) needs a theta-proxy API token so
|
||||
# sso-manager can create/update the relay Host route on its own.
|
||||
# - The Multi-Site modal's real gateway-mesh count needs a jump-host API
|
||||
# token (minted by a jump-admin user) to read GET /api/mesh/gateways.
|
||||
# Neither is required for the rest of the stack to work -- both features
|
||||
# just report "not configured" until you mint a token in each app's own web
|
||||
# UI (Settings -> API Tokens) and store it in OpenBao, from inside the
|
||||
# sso-manager container (VAULT_ADDR/VAULT_TOKEN are already set there):
|
||||
# docker compose exec sso-manager node -e "require('@simpleworkjs/bao-conf').set('integrations/theta-proxy', {token: 'prx_...'})"
|
||||
# docker compose exec sso-manager node -e "require('@simpleworkjs/bao-conf').set('integrations/theta-jump', {token: 'jmp_...'})"
|
||||
|
||||
# ── Optional outbound HTTP(S) proxy ──────────────────────────────────────────
|
||||
# For an isolated/offline/corporate-network test host that only reaches the
|
||||
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
|
||||
# Wired into every service's docker build (npm/apt) AND its running container
|
||||
# (SMTP, ACME/Let's Encrypt, DNS provider calls, the jump-host directory API
|
||||
# client). Leave unset to disable (the default); CFG_HTTPS_PROXY falls back to
|
||||
# CFG_HTTP_PROXY if unset, and CFG_NO_PROXY defaults to covering the stack's
|
||||
# own internal service names so container-to-container traffic never goes
|
||||
# through the proxy.
|
||||
#CFG_HTTP_PROXY=http://proxy.example.com:3128
|
||||
#CFG_HTTPS_PROXY=http://proxy.example.com:3128
|
||||
#CFG_NO_PROXY=localhost,127.0.0.1,sso-manager,proxy,jump-host
|
||||
|
||||
# Optional — sensible defaults if left blank:
|
||||
#CFG_ORG=SSO Manager # app display name + outbound email org
|
||||
#CFG_ADMIN_UID=admin # initial SSO admin username
|
||||
#CFG_ADMIN_EMAIL=admin@proxy.example.com # defaults to admin@<proxyHost>
|
||||
#CFG_LDAP_CERT_CN= # LDAP TLS cert CN; empty -> defaults to the domain
|
||||
|
||||
# Optional SMTP (outbound email from the SSO app). Leave blank to disable:
|
||||
#CFG_SMTP_HOST=smtp.example.com
|
||||
#CFG_SMTP_PORT=587
|
||||
#CFG_SMTP_USER=noreply@example.com
|
||||
#CFG_SMTP_PASS=your-smtp-password
|
||||
#CFG_SMTP_FROM=SSO Manager <noreply@example.com>
|
||||
#
|
||||
# Hostname advertised on the SSO /integrations page for direct LDAPS binds.
|
||||
# Leave blank to derive it from the public SSO host (same as oauth.issuer).
|
||||
# Recommended: set an internal-only name like 'ldap.internal.example.com' or
|
||||
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
|
||||
#CFG_LDAPS_HOST=
|
||||
|
||||
# ── DO NOT put secrets here ──────────────────────────────────────────────────
|
||||
# The LDAP admin password, JWT secret, admin password, LDAP service-account
|
||||
@@ -52,4 +135,42 @@ CFG_DOMAIN=example.com
|
||||
# password is the exception — see ./config/proxy-secrets.js's auth.localAdminPass
|
||||
# comment for how to actually change it after the account exists). Do NOT set
|
||||
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
|
||||
# CFG_PROXY_ADMIN_PASS here.
|
||||
# CFG_PROXY_ADMIN_PASS here.
|
||||
|
||||
# ── theta-agent Host Integration ─────────────────────────────────────────────
|
||||
# Configure theta-agent integration with the local host. All options default to
|
||||
# enabled (1). Set to 0 to disable.
|
||||
#
|
||||
# Enable theta-agent installation and configuration on this host.
|
||||
#CFG_THETA_AGENT_ENABLE=1
|
||||
#
|
||||
# Configure LDAP authentication for this host via ldap-client (SSSD/PAM).
|
||||
#CFG_THETA_AGENT_LDAP_AUTH=1
|
||||
#
|
||||
# Allow theta-agent full control of this host (arbitrary_bash, service_control,
|
||||
# reboot, configure_ldap capabilities).
|
||||
#CFG_THETA_AGENT_FULL_CONTROL=1
|
||||
|
||||
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
|
||||
# If you're joining a directory cluster (CFG_MASTER_DIRECTORY_URL/spoke.env
|
||||
# above), N-Way Multi-Master OpenLDAP replication is configured for you
|
||||
# automatically -- setup.sh's bootstrap/site-ldap-register.js asks the master
|
||||
# for a unique LDAP_SERVER_ID and the current list of every other site's LDAP
|
||||
# URL on every run (see docs/replication.md), restarting sso-manager only
|
||||
# when that config actually changed. Nothing to set here for the common case.
|
||||
#
|
||||
# Have a manually-coordinated LDAP MMR topology this script can't derive on
|
||||
# its own (e.g. peers outside this theta-suite cluster)? Set
|
||||
# CFG_LDAP_MMR_MANUAL=true to skip the automatic step entirely and set
|
||||
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS directly -- without this, the
|
||||
# automatic step runs on every deployment (every fresh install starts as a
|
||||
# master) and will overwrite them.
|
||||
#CFG_LDAP_MMR_MANUAL=true
|
||||
#LDAP_SERVER_ID=1
|
||||
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||
# ── Proxy HTTP/HTTPS Defaults ────────────────────────────────────────────────
|
||||
# If you are running the stack behind an external reverse proxy (like Cloudflare
|
||||
# or another ingress) that handles TLS termination, you may want the internal
|
||||
# proxy to serve everything over plain HTTP without forcing redirects to HTTPS.
|
||||
# Set this to 1 to create all default proxy host entries with forcessl=false.
|
||||
#CFG_CREATE_ALL_HTTP=1
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# spoke.env — join this stack to an existing Theta Directory as a read-only
|
||||
# spoke, instead of seeding a fresh master (MULTI_SITE_SPEC.md).
|
||||
#
|
||||
# This is the ONE place the join-a-cluster vars live -- split out of
|
||||
# setup.env.example (which still has every option, including these, for a
|
||||
# single-file bring-up) purely for clarity: standing up a spoke is a distinct
|
||||
# operation from configuring a fresh install, so it gets its own small file
|
||||
# instead of being buried among unrelated options. Set what you need here;
|
||||
# everything else (domain, admin creds, SMTP, ...) still comes from setup.env
|
||||
# as normal -- copy setup.env.example too and fill in CFG_DOMAIN there first.
|
||||
#
|
||||
# Same first-run-only rule as setup.env: read once (layered on top of
|
||||
# setup.env, so a var set in both places takes this file's value), then
|
||||
# ignored once ./config/ exists -- an already-running directory can never be
|
||||
# merged into a master's this way. The one exception is the no-inbound relay
|
||||
# vars at the bottom, which setup.sh re-checks on every run (see their
|
||||
# comment) since mesh peering usually finishes after the first bring-up.
|
||||
#
|
||||
# cp setup.env.example setup.env # if you haven't already -- set CFG_DOMAIN
|
||||
# cp spoke.env.example spoke.env
|
||||
# $EDITOR spoke.env # set CFG_MASTER_DIRECTORY_URL + _JOIN_KEY below
|
||||
# ./setup.sh
|
||||
#
|
||||
# Copying this file to spoke.env (gitignored) keeps your join key out of git.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# The master's URL and a site join key. Mint a key on the master:
|
||||
# Directory -> the Master Site modal -> Site Join Keys -> Mint key.
|
||||
# Both required to join; if either is unset this stack seeds a fresh master
|
||||
# instead (setup.env.example's normal behavior).
|
||||
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
|
||||
# This spoke's own public web domain, if it needs one independent of the
|
||||
# master's (an inbound spoke serving its own traffic directly -- see
|
||||
# CFG_SPOKE_NO_INBOUND below for the opposite case). Optional: CFG_DOMAIN
|
||||
# (in setup.env) is the shared LDAP identity namespace and must be identical
|
||||
# across every site in the cluster -- this only changes where THIS site's own
|
||||
# web hostnames (sso.*, proxy.*) point, never the LDAP base DN.
|
||||
#CFG_PUBLIC_DOMAIN=branch2.example.com
|
||||
|
||||
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
||||
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
||||
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
||||
# manual, out-of-band step on BOTH jump-hosts (mint a mesh join token on the
|
||||
# master's jump-host, paste it into this site's jump-host "Join a mesh" UI
|
||||
# action) that can't run unattended inside this script. Once that's done, set
|
||||
# these two and re-run setup.sh: it discovers this jump-host's assigned mesh
|
||||
# IP and registers it with the master, which then auto-creates the relay
|
||||
# route on its own theta-proxy. Safe to leave set before meshing -- setup.sh
|
||||
# just reports "not meshed yet" and skips until a later re-run finds the IP.
|
||||
#CFG_SPOKE_NO_INBOUND=true
|
||||
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-integration.sh — Full Docker integration test for theta-suite.
|
||||
#
|
||||
# Starts the sso-manager container in test mode (no secrets.js required),
|
||||
# seeds an LDAP test user, runs the full jest suite inside the container,
|
||||
# then tears everything down.
|
||||
#
|
||||
# Usage:
|
||||
# ./test-integration.sh # run all tests
|
||||
# ./test-integration.sh --no-build # skip docker build (reuse existing image)
|
||||
# ./test-integration.sh --keep # leave containers up after tests (for debugging)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${CYAN}[test]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[✗]${NC} $*"; exit 1; }
|
||||
|
||||
# ── Options ───────────────────────────────────────────────────────────────────
|
||||
NO_BUILD=0; KEEP=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
--keep) KEEP=1 ;;
|
||||
--help|-h) echo "Usage: $0 [--no-build] [--keep]"; exit 0 ;;
|
||||
*) warn "Unknown option: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Test environment config (self-contained, no secrets.js needed) ────────────
|
||||
export COMPOSE_PROJECT_NAME="theta-test"
|
||||
TEST_CONTAINER="theta-test-sso-manager-1"
|
||||
|
||||
LDAP_BASE_DN="dc=test,dc=local"
|
||||
LDAP_ADMIN_PASS="testadminpass"
|
||||
TEST_UID="test"
|
||||
TEST_PASSWORD="MyTestPassword!2" # must match tests/setup.js TEST_CREDS
|
||||
|
||||
# ── Cleanup on exit ───────────────────────────────────────────────────────────
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ "$KEEP" == "1" ]]; then
|
||||
warn "Leaving containers up (--keep). Tear down with: docker compose -p theta-test down -v"
|
||||
else
|
||||
info "Tearing down test stack..."
|
||||
docker compose -p theta-test -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||
fi
|
||||
exit $exit_code
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# ── Write a minimal test compose override ────────────────────────────────────
|
||||
info "Writing docker-compose.test.yml..."
|
||||
cat > docker-compose.test.yml <<'COMPOSEEOF'
|
||||
# Minimal test stack: sso-manager only (no proxy, no openbao, no jump-host).
|
||||
# Uses env-mode config — no secrets.js or openbao token required.
|
||||
services:
|
||||
sso-manager:
|
||||
build:
|
||||
context: ./sso-manager-node
|
||||
dockerfile: Dockerfile.openldap
|
||||
target: ""
|
||||
container_name: theta-test-sso-manager
|
||||
restart: "no"
|
||||
networks: [theta-test-net]
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- NODE_PORT=3001
|
||||
- LDAP_BASE_DN=dc=test,dc=local
|
||||
- LDAP_ADMIN_PASS=testadminpass
|
||||
- ORG_NAME=Test Org
|
||||
- LDAP_DOMAIN=test.local
|
||||
# Inline JWT secret for tests (no secrets.js or bao needed)
|
||||
- app_oauth__jwtSecret=test-integration-jwt-secret-theta42
|
||||
ports:
|
||||
- "13001:3001"
|
||||
- "10389:389"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 24
|
||||
start_period: 30s
|
||||
networks:
|
||||
theta-test-net:
|
||||
driver: bridge
|
||||
COMPOSEEOF
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
if [[ "$NO_BUILD" == "0" ]]; then
|
||||
info "Building sso-manager test image..."
|
||||
docker compose -p theta-test -f docker-compose.test.yml build sso-manager
|
||||
ok "Image built"
|
||||
else
|
||||
warn "Skipping build (--no-build)"
|
||||
fi
|
||||
|
||||
# ── Start ─────────────────────────────────────────────────────────────────────
|
||||
info "Starting sso-manager container..."
|
||||
docker compose -p theta-test -f docker-compose.test.yml up -d sso-manager
|
||||
|
||||
# ── Wait for healthy ──────────────────────────────────────────────────────────
|
||||
info "Waiting for sso-manager to become healthy (up to 120s)..."
|
||||
for i in $(seq 1 120); do
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' theta-test-sso-manager 2>/dev/null || echo "missing")
|
||||
if [[ "$STATUS" == "healthy" ]]; then
|
||||
ok "sso-manager is healthy"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 120 ]]; then
|
||||
warn "Container never became healthy. Logs:"
|
||||
docker logs theta-test-sso-manager --tail 60
|
||||
fail "sso-manager failed to become healthy after 120s"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Wait for LDAP ─────────────────────────────────────────────────────────────
|
||||
info "Waiting for LDAP on port 10389..."
|
||||
for i in $(seq 1 30); do
|
||||
if ldapsearch -x -H ldap://localhost:10389 -b "" -s base "(objectClass=*)" >/dev/null 2>&1; then
|
||||
ok "LDAP is ready"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 30 ]]; then
|
||||
fail "LDAP did not become reachable on localhost:10389 after 30s"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Seed test user ────────────────────────────────────────────────────────────
|
||||
info "Seeding test LDAP user via seed-test-user.sh..."
|
||||
|
||||
docker cp sso-manager-node/test/seed-test-user.sh theta-test-sso-manager:/tmp/seed-test-user.sh
|
||||
docker exec \
|
||||
-e LDAP_HOST=localhost \
|
||||
-e LDAP_PORT=389 \
|
||||
-e BIND_DN="cn=admin,${LDAP_BASE_DN}" \
|
||||
-e BIND_PW="${LDAP_ADMIN_PASS}" \
|
||||
-e BASE_DN="${LDAP_BASE_DN}" \
|
||||
theta-test-sso-manager \
|
||||
sh /tmp/seed-test-user.sh
|
||||
|
||||
ok "Test user seeded"
|
||||
|
||||
# ── Install dev deps (jest) inside the running container ──────────────────────
|
||||
info "Installing test dependencies (jest) inside container..."
|
||||
docker exec theta-test-sso-manager sh -c "
|
||||
cd /app &&
|
||||
if ! command -v jest >/dev/null 2>&1 && [ ! -f node_modules/.bin/jest ]; then
|
||||
npm install --save-dev jest@latest supertest@latest --silent 2>&1 | tail -3
|
||||
else
|
||||
echo 'jest already installed'
|
||||
fi
|
||||
"
|
||||
ok "Test deps ready"
|
||||
|
||||
# ── Copy test files into container ────────────────────────────────────────────
|
||||
info "Copying tests into container..."
|
||||
docker cp sso-manager-node/nodejs/tests/. theta-test-sso-manager:/app/tests/
|
||||
|
||||
ok "Tests copied"
|
||||
|
||||
# ── Run jest ──────────────────────────────────────────────────────────────────
|
||||
info "Running full jest test suite inside container..."
|
||||
echo ""
|
||||
|
||||
# These app_* vars are set by the entrypoint for the main process but NOT
|
||||
# inherited by docker exec subprocesses. Pass them explicitly so the jest
|
||||
# process loads app.js with the correct LDAP connection details.
|
||||
docker exec \
|
||||
-e NODE_ENV=test \
|
||||
-e REDIS_URL="redis://127.0.0.1:6379" \
|
||||
-e app_oauth__jwtSecret="test-integration-jwt-secret-theta42" \
|
||||
-e app_ldap__url="ldap://localhost:389" \
|
||||
-e app_ldap__bindDN="cn=admin,${LDAP_BASE_DN}" \
|
||||
-e app_ldap__bindPassword="${LDAP_ADMIN_PASS}" \
|
||||
-e app_ldap__userBase="ou=people,${LDAP_BASE_DN}" \
|
||||
-e app_ldap__groupBase="ou=groups,${LDAP_BASE_DN}" \
|
||||
theta-test-sso-manager \
|
||||
sh -c "
|
||||
cd /app
|
||||
# Write test conf with full LDAP connection details so jest workers get
|
||||
# the correct config without needing to inherit docker exec env vars.
|
||||
# app_* env vars are only applied at conf-module require time, but jest
|
||||
# workers may not reliably inherit them across all parallelism models.
|
||||
cat > /app/conf/test.js << CONFEOF
|
||||
'use strict';
|
||||
module.exports = {
|
||||
redis: { prefix: 'sso_manager_test_' },
|
||||
oauth: { jwtSecret: 'test-integration-jwt-secret-theta42' },
|
||||
ldap: {
|
||||
url: 'ldap://localhost:389',
|
||||
bindDN: 'cn=admin,${LDAP_BASE_DN}',
|
||||
bindPassword: '${LDAP_ADMIN_PASS}',
|
||||
userBase: 'ou=people,${LDAP_BASE_DN}',
|
||||
groupBase: 'ou=groups,${LDAP_BASE_DN}'
|
||||
}
|
||||
};
|
||||
CONFEOF
|
||||
echo 'conf/test.js written'
|
||||
REDIS_URL='redis://127.0.0.1:6379' node_modules/.bin/jest --forceExit --passWithNoTests 2>&1
|
||||
"
|
||||
JEST_EXIT=$?
|
||||
|
||||
echo ""
|
||||
if [[ $JEST_EXIT -eq 0 ]]; then
|
||||
ok "All jest tests passed!"
|
||||
else
|
||||
fail "Some jest tests failed (exit code $JEST_EXIT)"
|
||||
fi
|
||||
|
||||
# ── Theta-agent Go tests (host-side, no docker needed) ───────────────────────
|
||||
if command -v go >/dev/null 2>&1 && [[ -d theta-agent ]]; then
|
||||
info "Running theta-agent Go tests..."
|
||||
(cd theta-agent && go test ./... -count=1 2>&1)
|
||||
ok "Theta-agent Go tests passed"
|
||||
else
|
||||
warn "Skipping theta-agent Go tests (go not found or theta-agent dir missing)"
|
||||
fi
|
||||
|
||||
ok "All integration tests complete!"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// Regression guard for bootstrap.js's generated jump-secrets.js template:
|
||||
// its ldap block must use ldaps:// (implicit TLS, :636), never ldap:// (:389),
|
||||
// as long as tlsOptions is set alongside it.
|
||||
//
|
||||
// ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless of URL
|
||||
// scheme, and jump-host's LDAP client always sets tlsOptions -- so ldap://
|
||||
// + tlsOptions opens a raw TLS handshake against a port serving plaintext
|
||||
// LDAP. The server silently drops the connection before any LDAP message
|
||||
// parses, and every operation (getUser, checkPassword, ...) then fails
|
||||
// identically -- indistinguishable from a wrong password. This shipped once
|
||||
// (every SSH login to jump-host failed, for any account, any password) before
|
||||
// being root-caused against a real deployment. Static, not a require()+exec
|
||||
// of bootstrap.js, because bootstrap.js is a self-running provisioning script
|
||||
// with real side effects (LDAP writes, API calls), not a library.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BOOTSTRAP_PATH = path.join(__dirname, '..', 'bootstrap', 'bootstrap.js');
|
||||
const src = fs.readFileSync(BOOTSTRAP_PATH, 'utf8');
|
||||
|
||||
// Isolate the generated jump-secrets.js template (the backtick string
|
||||
// assigned to `body` inside writeJumpSecrets) rather than scanning the whole
|
||||
// file, so this only ever looks at what's actually written to the deployed
|
||||
// config -- not, say, a comment or an unrelated ldap:// URL elsewhere.
|
||||
// bootstrap.js's own source has literal backslash-t escape sequences inside
|
||||
// the backtick string (they only become real tabs when the template
|
||||
// literal is actually evaluated) -- so these patterns match `\t` as two
|
||||
// literal characters, not a real tab byte.
|
||||
const bodyMatch = /const body = `([\s\S]*?)`;\n\tfs\.writeFileSync\(JUMP_SECRETS/.exec(src);
|
||||
if (!bodyMatch) {
|
||||
console.error('check_jump_ldap_tls: could not locate the jump-secrets.js template in bootstrap.js — did writeJumpSecrets change shape?');
|
||||
process.exit(1);
|
||||
}
|
||||
const template = bodyMatch[1];
|
||||
|
||||
// Bounded by the next top-level key (sso:) rather than the ldap block's own
|
||||
// closing brace, which is more robust to exactly how it's indented/escaped.
|
||||
const ldapBlockMatch = /ldap:\s*\{([\s\S]*?)\\tsso:\s*\{/.exec(template);
|
||||
if (!ldapBlockMatch) {
|
||||
console.error('check_jump_ldap_tls: could not find the ldap: {...} block in the jump-secrets.js template.');
|
||||
process.exit(1);
|
||||
}
|
||||
const ldapBlock = ldapBlockMatch[1];
|
||||
|
||||
const hasTlsOptions = /tlsOptions\s*:/.test(ldapBlock);
|
||||
const urlMatch = /url:\s*'([^']+)'/.exec(ldapBlock);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
|
||||
if (!url) {
|
||||
console.error('check_jump_ldap_tls: no url found in the ldap block.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (hasTlsOptions && !url.startsWith('ldaps://')) {
|
||||
console.error(
|
||||
`check_jump_ldap_tls: jump-secrets.js template sets tlsOptions but url is "${url}" (not ldaps://). ` +
|
||||
'This is the exact bug that broke every SSH login to jump-host -- see the comment above this check.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`check_jump_ldap_tls: OK (url=${url}, tlsOptions=${hasTlsOptions})`);
|
||||
@@ -0,0 +1,40 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
test('Integration Test Suite', async (t) => {
|
||||
|
||||
await t.test('SSO Manager should be running and healthy', async () => {
|
||||
const res = await fetch('http://localhost:3001/health');
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.status, 'ok');
|
||||
});
|
||||
|
||||
await t.test('Proxy should be running and route to SSO Manager', async () => {
|
||||
// Testing the proxy routes traffic to SSO manager
|
||||
const res = await fetch('http://sso.localtest.me/.well-known/openid-configuration');
|
||||
assert.ok(res.status === 200 || res.status === 301);
|
||||
const body = await res.json();
|
||||
assert.ok(body.issuer);
|
||||
});
|
||||
|
||||
await t.test('Proxy Management API should be running', async () => {
|
||||
const res = await fetch('http://localhost:3000/health');
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.status, 'ok');
|
||||
});
|
||||
|
||||
await t.test('OpenBao should be running and healthy', async () => {
|
||||
// Port 8080 is mapped to OpenBao's 8200 in docker-compose.yml
|
||||
const res = await fetch('http://localhost:8080/v1/sys/health');
|
||||
assert.ok(res.status === 200 || res.status === 501); // 501 means not initialized/sealed, but responsive
|
||||
});
|
||||
|
||||
await t.test('SSO Manager should proxy to OpenBao (integration test)', async () => {
|
||||
// Test if SSO Manager proxies to OpenBao
|
||||
// Without authentication, this should return 401 Unauthorized from SSO Manager's middleware
|
||||
const res = await fetch('http://localhost:3001/api/vault/sys/health');
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "theta-env-integration-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "Automated integration tests for theta-env projects",
|
||||
"scripts": {
|
||||
"test": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --test *.test.js"
|
||||
}
|
||||
}
|
||||