Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f27ce70c5d | |||
| 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 |
@@ -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`.
|
# 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.
|
# All values are read by setup.sh / docker-compose / the bootstrap.
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
name: Lint
|
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
|
# proxy/sso-manager-node submodules) -- this checks the one thing that can
|
||||||
# actually break silently: setup.sh and bootstrap.js, plus a static
|
# actually break silently: setup.sh and bootstrap.js, plus a static
|
||||||
# consistency check on the config bootstrap.js generates for jump-host
|
# consistency check on the config bootstrap.js generates for jump-host
|
||||||
|
|||||||
@@ -11,3 +11,6 @@
|
|||||||
[submodule "ldap-client"]
|
[submodule "ldap-client"]
|
||||||
path = ldap-client
|
path = ldap-client
|
||||||
url = https://github.com/theta42/ldap-client.git
|
url = https://github.com/theta42/ldap-client.git
|
||||||
|
[submodule "theta-agent"]
|
||||||
|
path = theta-agent
|
||||||
|
url = https://github.com/theta42/theta-agent.git
|
||||||
|
|||||||
@@ -2,18 +2,473 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`). Entries here cover theta-env's own
|
correspond to git tags (`vX.Y.Z`). Entries here cover theta-suite's own
|
||||||
orchestration code; see each submodule's own `CHANGELOG.md`
|
orchestration code; see each submodule's own `CHANGELOG.md`
|
||||||
([proxy](https://github.com/theta42/proxy/blob/master/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))
|
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md))
|
||||||
for what changed inside the apps it composes.
|
for what changed inside the apps it composes.
|
||||||
|
|
||||||
|
## [v1.40.0] - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **No more spurious "Invalid Credentials, login failed" during LDAP enrollment** (ldap-client v1.25.0, gitlink `68fcdb5`) — `index.sh` self-registered the host in the Directory when `sso_token` was *declared but empty* (it checked `[[ -v ]]`), POSTing an empty Bearer token and getting a misleading `LDAPLoginFailed`. It now only registers with a real token; the stack host (already seeded by the bootstrap) skips registration.
|
||||||
|
- **The `cn=ldapclient` service account now shows in the SSO Users UI** — it was created as a bare `organizationalRole` (invisible to the `posixAccount` user filter) and never joined `app_sso_service_account`, so it never appeared as a service account. The bootstrap now creates it as a `posixAccount` (uid 10001, above the regular-user reserved floor) and adds it to `app_sso_service_account`; for an existing account it best-effort adds the `posixAccount` shape (auxiliary, so it can't conflict with the structural `organizationalRole`) + the group membership.
|
||||||
|
|
||||||
|
## [v1.39.0] - 2026-08-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Plain LDAP (389) now reachable from the host** — `docker-compose.yml` published only LDAPS (636); plain LDAP (389) was deliberately not mapped, so the stack host's own enrollment (`setup.sh` → ldap-client, which configures sssd against `ldap://localhost:389` and `ldaps://localhost:636`) could not reach the directory over loopback. Both 389 and 636 are now published to the host (bind 0.0.0.0; `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to lock to the host only).
|
||||||
|
|
||||||
|
## [v1.38.0] - 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **LDAP enrollment no longer reaches for the public domain** — `setup.sh` generated `ldap.vars` with `ldap_host` defaulting to the public SSO host (`sso.<domain>`), which the NAT/firewall blocks on the LDAP ports (389/636). It now defaults to `localhost` (the LDAP server is co-located on the stack host; `ldap_tls_reqcert=never` makes this safe), overridable with `CFG_LDAPS_HOST` for an internal hostname/IP.
|
||||||
|
- **SSH access groups match the SSO group model** (ldap-client v1.24.0) — the generated `sssd.conf` access filter and `ldap-ssh-key.sh` referenced the legacy names (`<location>_access`, `app_super_admin`); they now use `site_<location>_hosts_access` (all-hosts aggregate), `site_<location>_host_<hostname>_access`, and `god_admin`. GROUPS.md §8's example updated to match.
|
||||||
|
|
||||||
|
## [v1.37.0] - 2026-08-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Group naming corrected to match docs/GROUPS.md** — per-resource groups are `{site}_{kind}_{name}_{level}` (kind always present; a host `host_theta-env` → `site_local_host_theta-env_access`, a service → `site_local_app_sso-manager_access`). The spec's §3 text was updated to state this explicitly.
|
||||||
|
- **Roll up sso v1.27.0** — group names match the docs, a site carries only god + site-wide groups, duplicate group links removed, `/api/agent/*` no longer 404s, shared-secrets POST/GET fixed, Vault Apps tab lists minted tokens, discovery promote + plugin run logs fixed. See the [sso changelog](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md).
|
||||||
|
|
||||||
|
## [v1.36.1] - 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`setup.sh` no longer aborts with `CFG_BASE_DN: unbound variable`** — the ldap-client `ldap.vars` generation read the CFG_* first-run vars, which `ensure_config` only derives once (it returns early on a re-run once `sso-secrets.js` exists). It now reads the real values from the operator-owned `./config/sso-secrets.js` when the CFG_* vars are unset, so LDAP enrollment works on re-runs too. The generated `ldap_access_groups` now references `god_admin` (the legacy `app_super_admin` is gone).
|
||||||
|
- **Roll up sso v1.26.1** — drops the legacy `app_super_admin`: `SUPER_ADMIN_GROUP` is now `god_admin` (nested into every resource's `_admin` group), and `docker-entrypoint.sh` no longer seeds/nests `app_super_admin`. See the [sso changelog](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md).
|
||||||
|
|
||||||
|
## [v1.36.0] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`god_admin` seeded + site groups auto-provisioned** (sso v1.26.0) — `god_admin` exists from first boot; every site gets `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource groups (`{site}_{slug}_{level}`) nest into the site aggregates (the inheritance lattice now exists in LDAP, not just the resolver). See the sso changelog for the full group-model completeness + server-side naming enforcement + Directory god_admin management.
|
||||||
|
- **Docker discovery plugin configured out of the box** — the bootstrap seeds a `docker-local` plugin instance pointed at `/var/run/docker.sock`, so a fresh stack discovers its own containers into the Directory immediately (idempotent; an operator-created instance is left alone).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ldap-client enrollment no longer fails** — `setup.sh` was calling `ldap-client/index.sh`, which refuses to run without a gitignored `ldap.vars` that nothing ever created (the "ldap.vars file not found!" + "enrollment failed" you saw). It now generates `ldap-client/ldap.vars` from the stack's own config (LDAPS host, base DN, `cn=ldapclient` bind + service password, SSO URL, site name) before enrolling; an operator-provided `ldap.vars` is always kept.
|
||||||
|
- **theta-agent no longer logs `Unknown command type: heartbeat_ack`** every minute — the server's ack of the agent's own heartbeat is now silently ignored instead of falling through to the unknown-command handler (which also answered with a spurious error).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Roll up sso v1.26.0 + theta-agent v1.3.0** — gitlinks point at the version-tagged commits for both submodules (sso-manager-node → 8a9de94, theta-agent → 52379c2). Full changelogs: [sso](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md), [theta-agent](https://github.com/theta42/theta-agent/blob/master/CHANGELOG.md).
|
||||||
|
|
||||||
|
## [v1.35.18] - 2026-08-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Sync proxy + jump-host gitlinks to their version-tagged commits** — proxy v1.33.0 and jump-host v1.18.0 bumped their package.json to match their tags; this release picks up those corrected gitlinks so a fresh deploy reports the matching versions.
|
||||||
|
|
||||||
|
## [v1.35.17] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Group & Permission Model spec** — canonical documentation of the hierarchical group schema (`god_admin`, `{site}_super_admin`, per-site/per-resource host+app `admin`/`access`/`<capability>` groups, meta `everyone`/`{site}_everyone`), the inheritance resolver, Directory-only group management, multi-site isolation, host-side SSSD GID mapping (groups are `groupOfNames`, no `gidNumber`), downstream-app consumption, and migration from the legacy `app_*` groups. See [GROUPS.md](GROUPS.html).
|
||||||
|
- **sso v1.25.0** — the resolver + schema implemented in the SSO (see its changelog); the standalone Groups page removed.
|
||||||
|
|
||||||
|
## [v1.35.16] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **theta-proxy + theta-jump as first-class managed host resources** — the bootstrap now seeds them as managed `host`-kind resources in the Directory (in addition to the existing stack host and its service entries), so a fresh install shows them as hosts.
|
||||||
|
|
||||||
|
## [v1.35.15] - 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **theta-agent re-install failed with "Text file busy"** — setup.sh copied the prebuilt binary over a running agent service, which cp refuses. It now stops the service before copying.
|
||||||
|
|
||||||
|
## [v1.35.14] - 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The recurring `/vault` 403 "permission denied" is actually dead this time — it was never a policy problem.** sso's `/api/vault` proxy declared its request hook with http-proxy-middleware **v3** syntax (`on: { proxyReq }`) while the app installs HPM **v2**, which silently ignores the unknown key — so `X-Vault-Token` was never injected and every vault call reached OpenBao unauthenticated. All the policy work of v1.35.10/v1.31.1 was correct and is unchanged; the requests just never carried a token. Ships as **sso v1.23.0** (see its changelog for the companion `fixRequestBody` header-ordering fix and the initORM schema heal that unbreaks the plugin scheduler on upgraded databases).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **OpenBao token lifecycle — nothing expires by surprise anymore.**
|
||||||
|
- New **`theta-svc` token role** (periodic 768h): `SSO/PROXY/JUMP_VAULT_TOKEN` are now minted through it instead of as plain orphan tokens with a hard ~32-day death date. `ensure_token` renews periodic tokens on every `setup.sh` re-run and detects, revokes, and re-mints valid-but-non-periodic tokens from older installs (detection is the token's `role` — OpenBao token lookup does not expose a `period` field).
|
||||||
|
- New **`bao-renewer` sidecar** (docker-compose): renews the three service tokens every 12h while the stack runs, logging each result. Recreated on every `setup.sh` run so it always holds the current tokens.
|
||||||
|
- New **`sso-app` token role** (periodic 768h): external-app tokens minted from the vault UI go through it instead of the broker's 24h role, and sso now stores each app token's *accessor* and auto-renews it (boot + every 6h) — a downstream app's credential stays valid as long as sso runs, with no renewal code in the downstream app.
|
||||||
|
- `sso-broker` policy gained `update` on `auth/token/create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`.
|
||||||
|
- `docs/secrets.md` rewritten around the new lifecycle (roles table, renewal layers, disaster recovery).
|
||||||
|
|
||||||
|
## [v1.35.13] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **sso v1.22.0** — new **Agents** page (live list of connected theta-agent hosts with CPU/RAM/disk/ZFS/GPU telemetry + online status) and a security fix gating the `/api/agent` REST routes. Bumped the sso-manager-node gitlink to v1.22.0.
|
||||||
|
|
||||||
|
## [v1.35.12] - 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **theta-agent crash-looped (`cannot unmarshal !!bool 'true' into []string`)** — setup.sh's "full control" edit wrote `service_control: true`, but that field is a `[]string` allowlist, so the agent failed to decode the config and restart-loop. Removed the invalid edit; `service_control` now stays as its allowlist (default `[]` = deny all) and the operator can list specific services.
|
||||||
|
|
||||||
|
## [v1.35.11] - 2026-08-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`setup.sh` aborted with `UNSEAL_KEY: unbound variable` on re-runs** — when OpenBao was already unsealed, the unseal block was skipped and `UNSEAL_KEY` was never set, so the later `if [[ -n "$UNSEAL_KEY" ]]` crashed under `set -u`. Guarded with `${UNSEAL_KEY:-}`.
|
||||||
|
|
||||||
|
## [v1.35.10] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`--reset-openbao`** — full clean OpenBao reset for clearing stale policies/tokens (re-inits the store, flushes the Redis vault-token cache). Use when the vault UI shows a recurring `403 permission denied` on the secrets list.
|
||||||
|
- **sso v1.21.0** — shared secrets: users publish secrets to `secret/shared/<owner>/<slug>` and grant read access to other users and apps; plus a durable fix for the recurring vault 403 (broker now always reconciles policy content before serving a cached token). Bumped the sso-manager-node submodule gitlink to v1.21.0.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **theta-agent was never installed** — `setup.sh` tried to `go build` from an incomplete source-file list (omitting `executor.go`/`telemetry.go`), which failed silently and skipped install. It now installs the prebuilt `theta-agent-linux-amd64` binary from the submodule and writes config to `/etc/theta42/agent.yml` (the path the agent actually reads).
|
||||||
|
|
||||||
|
## [v1.35.9] - 2026-08-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **sso & proxy version strings now match their release tags** — The v1.20.2 / v1.32.0 release tags were created but their `nodejs/package.json` version fields were left behind (1.20.1 / 1.14.3), so the deployed apps' update-check banner falsely reported a newer version. Bumped submodules to the corrected commits so `buildVersion` matches the deployed tag.
|
||||||
|
|
||||||
|
## [v1.35.2] - 2026-08-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Unbound `CFG_CREATE_ALL_HTTP` variable in `setup.sh`** — Fixed unbound variable error during host registration in `setup.sh` when `ensure_secrets_files()` is skipped on pre-configured installations.
|
||||||
|
|
||||||
|
## [v1.35.1] - 2026-08-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Directory & Configuration UI enhancements** — Live Cytoscape graph update on parent/child edge modifications, improved discovery reconciler host matching, updated configuration sidebar layout, relocated discovery and messaging plugins to Directory and Configuration pages.
|
||||||
|
- **Managed Host Target Filter** — Filter SSH connection targets in Jump Host to managed hosts only.
|
||||||
|
|
||||||
|
## [v1.35.0] - 2026-08-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Non-interactive theta-agent configuration** — Added three `setup.env` variables
|
||||||
|
to control theta-agent installation and configuration without interactive prompts:
|
||||||
|
- `CFG_THETA_AGENT_ENABLE` (default: 1) — Enable theta-agent installation
|
||||||
|
- `CFG_THETA_AGENT_LDAP_AUTH` (default: 1) — Configure LDAP authentication via ldap-client
|
||||||
|
- `CFG_THETA_AGENT_FULL_CONTROL` (default: 1) — Enable all agent capabilities
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`setup.sh`**: Made theta-agent setup fully non-interactive, driven by `setup.env`
|
||||||
|
variables. Defaults preserve existing behavior (all features enabled).
|
||||||
|
|
||||||
|
## [v1.34.0] - 2026-08-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **theta-agent**: Added the agent submodule and C2 WebSocket endpoint integrations to the suite.
|
||||||
|
- **PKI Certificates**: Integrated PKI certificate generation and management capabilities.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Submodules bumped**:
|
||||||
|
- `sso-manager-node` updated to `v1.19.2` (Includes Discovery graph merge fix).
|
||||||
|
- `proxy` updated to `v1.14.1` (Removed invalid documentation copy from Dockerfile).
|
||||||
|
- `jump-host` updated to `v1.16.1`.
|
||||||
|
- **`setup.sh`**: Added robust `|| true` fallback to Redis `LASTSAVE` and `CONFIG GET` commands to gracefully bypass snapshoting if the target container is in a crash-loop.
|
||||||
|
- **Docs**: Removed all standalone deployment documentation to officially deprecate standalone mode.
|
||||||
|
- **CI/CD**: Removed redundant submodule unit test jobs from the main orchestration pipeline.
|
||||||
|
|
||||||
|
## [v1.33.0] - 2026-08-02
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Submodules bumped** for OpenBao secret integration.
|
||||||
|
|
||||||
|
## [v1.32.0] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **CI/CD**: Added robust GitHub Actions CI/CD workflows for the suite.
|
||||||
|
- **Docs**: Updated plugin ecosystem documentation.
|
||||||
|
|
||||||
|
## [v1.31.1] - 2026-08-01
|
||||||
|
|
||||||
|
Pairs the sso v1.17.2 post-deploy fixes with the theta-suite half of the
|
||||||
|
`/vault` secrets-list 403 fix (the `sso-admin` OpenBao policy grant that lives
|
||||||
|
in `setup.sh`), and rolls the `sso-manager-node` submodule gitlink to v1.17.2.
|
||||||
|
`proxy` (v1.13.1), `jump-host` (v1.14.1), and `ldap-client` (v1.23.0) are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Changed (theta-suite)
|
||||||
|
- **`setup.sh` — `sso-admin` policy**: added a `list` grant on the bare KV mount
|
||||||
|
root `secret/metadata` so an admin can list the top-level dirs in the `/vault`
|
||||||
|
UI. `secret/metadata/*` already covered nested paths, but not the mount root
|
||||||
|
itself — so the secrets list 403'd. (The matching per-user/per-app directory
|
||||||
|
grants ship in sso v1.17.2's `vault_broker.js`.)
|
||||||
|
- **`setup.sh` — `ensure_policy`**: now always (re)writes the policy instead of
|
||||||
|
skipping when it exists. `bao policy write` is an idempotent overwrite, so a
|
||||||
|
re-run applies policy edits (like the new grant above) instead of stranding
|
||||||
|
the old HCL with "already exists — keeping."
|
||||||
|
|
||||||
|
### Changed (submodule gitlinks)
|
||||||
|
- **sso-manager-node**: `v1.17.1` → `v1.17.2` — the post-deploy fixes (auto-slug
|
||||||
|
plugins, schedule dropdown, `/profile` rendering, plugin-edit persistence,
|
||||||
|
nmap in the image, the sso-side `/vault` policy grants) plus the SMS (VoIP.ms)
|
||||||
|
and Terms-of-Service configuration on `/conf`. Full changelog below.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
Operators upgrading from v1.31.0:
|
||||||
|
1. `git pull` and `git submodule update --init --recursive`.
|
||||||
|
2. Re-run `./setup.sh` — **required**: applies the new `sso-admin`
|
||||||
|
`secret/metadata` list grant and the `ensure_policy` always-write refresh
|
||||||
|
(idempotent). Per-user vault policies self-heal on the next `/vault` visit
|
||||||
|
(sso v1.17.2 re-writes them).
|
||||||
|
3. `docker compose build && docker compose up -d` — the rebuild installs `nmap`
|
||||||
|
in the sso image (fixes the nmap plugin "not found" error).
|
||||||
|
|
||||||
|
### Bundled submodule release notes
|
||||||
|
|
||||||
|
#### sso-manager-node v1.17.2 — post-deploy fixes + SMS/TOS on /conf
|
||||||
|
|
||||||
|
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
|
||||||
|
Terms-of-Service configuration the `/conf` page was missing.
|
||||||
|
|
||||||
|
##### Fixed
|
||||||
|
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
|
||||||
|
modal no longer asks for a Slug (it derives a stable, unique handle from the
|
||||||
|
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
|
||||||
|
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
|
||||||
|
optional; an explicit slug is still accepted and validated.
|
||||||
|
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
|
||||||
|
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
|
||||||
|
is still a cron string, so the server is unchanged.
|
||||||
|
- **`/vault` secrets list no longer 403s.** The per-user, per-app, and admin
|
||||||
|
OpenBao policies granted `list` only on `secret/metadata/.../*` (nested
|
||||||
|
paths), never on the directory path itself — so listing a directory's
|
||||||
|
*contents* (which checks `list` on the directory, e.g.
|
||||||
|
`secret/metadata/users/<uid>` or the mount root `secret/metadata`) was denied.
|
||||||
|
`vault_broker.js`'s `userPolicyHcl`/`appPolicyHcl` now also grant `list` on the
|
||||||
|
bare directory path, and `ensurePolicy` now always re-writes the policy
|
||||||
|
(idempotent) so already-created `user-<uid>` policies pick up the new grant on
|
||||||
|
the next vault-page visit. The matching `sso-admin` mount-root grant ships in
|
||||||
|
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
|
||||||
|
always-write so re-running `./setup.sh` applies policy edits.
|
||||||
|
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
|
||||||
|
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
|
||||||
|
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
|
||||||
|
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
|
||||||
|
buttons. The header/label are now populated by JS (the `Members` label
|
||||||
|
already had a setter pointing at a missing id); the Admin Actions block is
|
||||||
|
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
|
||||||
|
and the correct Activate/Deactivate button shows.
|
||||||
|
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
|
||||||
|
masked secret values and rendered them as fields, but `PUT /:id` only saves
|
||||||
|
non-secret config — so an edited secret was silently dropped. The Edit modal
|
||||||
|
now shows **non-secret fields only** (secrets have their own Edit-Secrets
|
||||||
|
modal), removing the confusion.
|
||||||
|
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
|
||||||
|
binary was not installed in the app image. `Dockerfile.openldap` now `apk
|
||||||
|
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
|
||||||
|
the opaque node-nmap spawn-missing error into an actionable `lastError`.
|
||||||
|
|
||||||
|
##### Added
|
||||||
|
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
|
||||||
|
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
|
||||||
|
config files. It now has an SMS card on `/conf` (API username, DID, API
|
||||||
|
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
|
||||||
|
the API password masked (`********`) and leave-blank-to-keep — mirroring the
|
||||||
|
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
|
||||||
|
saved change takes effect live without a restart.
|
||||||
|
- **Terms of Service editor moved to `/conf`** from the admin Overview
|
||||||
|
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
|
||||||
|
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
|
||||||
|
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
|
||||||
|
stats, notifications, and metrics.
|
||||||
|
|
||||||
|
## [v1.31.0] - 2026-08-01
|
||||||
|
|
||||||
|
Roll-up release: bumps the composed submodules to their latest tags so a fresh
|
||||||
|
`git clone` + `./setup.sh` deploys the SSO Manager plugin system, the `/conf`
|
||||||
|
SMTP/OAuth secret masking, and the ldap-client changelog. `proxy` (v1.13.1) and
|
||||||
|
`jump-host` (v1.14.1) were already at latest and are unchanged.
|
||||||
|
|
||||||
|
### Changed (submodule gitlinks)
|
||||||
|
- **sso-manager-node**: `v1.16.1` → `v1.17.1` (the plugin system shipped in
|
||||||
|
v1.17.0, plus the v1.17.1 `/conf` secret-masking hardening).
|
||||||
|
- **ldap-client**: `v1.1.1` → `v1.23.0` — a CHANGELOG-only release (the new
|
||||||
|
`CHANGELOG.md` documenting v1.1.0/v1.0.0; **no code change** — the "UI polish"
|
||||||
|
tag message is misleading, the v1.1.1…v1.23.0 diff is `CHANGELOG.md` only).
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
Operators upgrading from a prior release:
|
||||||
|
1. `git pull` and `git submodule update --init --recursive` (or a fresh clone).
|
||||||
|
2. Re-run `./setup.sh` — this is **required** if you haven't yet applied the
|
||||||
|
v1.30.1 `sso-broker` OpenBao policy grant for `secret/plugins/*` (idempotent;
|
||||||
|
it grants the existing `SSO_VAULT_TOKEN` access live, so plugin-secrets
|
||||||
|
storage works).
|
||||||
|
3. `docker compose build && docker compose up -d`. Existing
|
||||||
|
`conf.discovery.plugins` setups auto-migrate into `PluginInstance` rows +
|
||||||
|
OpenBao secrets on first boot of sso v1.17.x.
|
||||||
|
|
||||||
|
### Bundled submodule release notes
|
||||||
|
|
||||||
|
#### sso-manager-node v1.17.0 — real plugin system (loadable instances + OpenBao secrets)
|
||||||
|
|
||||||
|
## [1.17.0] - 2026-08-01
|
||||||
|
|
||||||
|
A real **plugin system**: the half-built discovery plugins (statically
|
||||||
|
configured in `sso-secrets.js`, only toggleable for cron/enabled) become
|
||||||
|
**configurable, loadable/unloadable plugin instances** you manage from a
|
||||||
|
dedicated **Plugins** page and the `/api/plugins` API, with multiple runtime
|
||||||
|
copies of each type and per-instance secrets stored in OpenBao.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Plugin instances** — a new `PluginInstance` ORM model
|
||||||
|
(`nodejs/models/plugin_instance.js`, Sequelize) is the registry of
|
||||||
|
configured, scheduled plugin copies. Each has a `pluginType`, a unique
|
||||||
|
`slug` (the discovery source name), a cron schedule, an `enabled` flag
|
||||||
|
(load/unload), non-secret `config` (JSON), and last-run bookkeeping. Multiple
|
||||||
|
instances of the same type are supported.
|
||||||
|
- **Plugin registry** (`nodejs/services/plugin_registry.js`) — generalizes the
|
||||||
|
one-shot discovery-plugin scan in `scheduler.js`. Plugin types are modules
|
||||||
|
under `nodejs/plugins/<category>/<type>.js` exporting a manifest
|
||||||
|
(`type`, `category`, `name`, `description`, `configSchema`, `validate`,
|
||||||
|
`run`/`discover`). Exposes `getTypes`, `getModule`, `splitConfig` (secret vs
|
||||||
|
non-secret), `mask`, and required-field helpers for the UI/API.
|
||||||
|
- **Per-instance secrets in OpenBao** (`nodejs/utils/plugin_secrets.js`) —
|
||||||
|
`configSchema` fields flagged `secret:true` (e.g. a Proxmox `tokenSecret`,
|
||||||
|
UniFi `password`) are stored at `secret/plugins/<instance-id>/conf`, never in
|
||||||
|
the DB. The UI only ever sees masked (`********`) values. Plugins run
|
||||||
|
in-process (BullMQ workers), so they need no OpenBao token of their own — the
|
||||||
|
SSO reads/writes via the `sso-broker` token. **Requires theta-suite ≥ v1.30.1**
|
||||||
|
for the `sso-broker` policy grant on `secret/plugins/*`; the API fails-soft
|
||||||
|
with a clear error if absent.
|
||||||
|
- **`/api/plugins` API** (`nodejs/routes/api_plugins.js`, replaces the old
|
||||||
|
`routes/plugins.js`) — `GET /types`, list/get/create/update/update-secrets/
|
||||||
|
test/load/unload/run/delete/runs. Admin-only
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`).
|
||||||
|
- **Plugins page** (`/plugins`, `views/plugins.ejs`) + nav entry — instance
|
||||||
|
table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms
|
||||||
|
rendered from each type's `configSchema`.
|
||||||
|
- **`validate`** ("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `services/scheduler.js` now schedules from the `PluginInstance` table instead
|
||||||
|
of static `conf.discovery.plugins` + a Redis override hash. Each instance owns
|
||||||
|
a stable BullMQ JobScheduler id (`plugin:<instanceId>`) so load/unload
|
||||||
|
upsert/remove one schedule without disturbing the rest. Discovery plugins
|
||||||
|
reconcile results under the instance's `slug`.
|
||||||
|
- The three discovery plugins (`plugins/discovery/{proxmox,unifi,nmap}.js`)
|
||||||
|
gained manifests (`configSchema`, `validate`, `run` alias). `nmap`'s
|
||||||
|
`targetRange` is non-secret; Proxmox `tokenSecret` and UniFi `password` are
|
||||||
|
secret.
|
||||||
|
- The `/plugins` page route renders the page instead of redirecting to
|
||||||
|
`/directory`; the **Agents & Scheduler** tab was removed from `/directory`
|
||||||
|
(plugins are now managed on the Plugins page). The `/docs/agents` link is
|
||||||
|
aliased to `/docs/plugins`.
|
||||||
|
- `docs/plugins.md`, `docs/vault.md`, `docs/_config.yml` (nav), and `API.md`
|
||||||
|
(Plugin Endpoints section) document the new system.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
On first boot of v1.17.0, if the `PluginInstance` table is empty **and**
|
||||||
|
`conf.discovery.plugins` has entries, one instance per configured type is seeded
|
||||||
|
automatically (secret fields copied into OpenBao). After that the static
|
||||||
|
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
|
||||||
|
empty-table check).
|
||||||
|
|
||||||
|
### Prerequisite
|
||||||
|
**theta-suite ≥ v1.30.1** — re-run `./setup.sh` after upgrading so the
|
||||||
|
`sso-broker` OpenBao policy is granted `secret/plugins/*`. Without it, storing
|
||||||
|
plugin secrets fails with a clear error.
|
||||||
|
|
||||||
|
|
||||||
|
#### sso-manager-node v1.17.1 — mask SMTP/OAuth secrets + leave-blank-to-keep on /conf
|
||||||
|
|
||||||
|
## [1.17.1] - 2026-08-01
|
||||||
|
|
||||||
|
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
|
||||||
|
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
|
||||||
|
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
|
||||||
|
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
|
||||||
|
(unchanged) — only how they're surfaced to the admin changes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
|
||||||
|
(was: returned in cleartext). Non-secret fields (host, port, user, from,
|
||||||
|
secure, issuer, token lifetimes) are returned as before.
|
||||||
|
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
|
||||||
|
as "keep the current stored value" — so an admin editing the From address or
|
||||||
|
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
|
||||||
|
secret. Only a genuinely new, non-blank value overwrites. The preserved values
|
||||||
|
are re-applied to live `conf` immediately, as before.
|
||||||
|
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
|
||||||
|
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
|
||||||
|
copy notes secret fields are masked. No JSON-textarea editing is involved —
|
||||||
|
SMTP is and remains configured through structured form fields.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
|
||||||
|
this release (via `POST /api/conf` → `baoConf.set('sso-manager/conf')`, and
|
||||||
|
overlaid back at boot by `bao-conf.init`). This release closes the
|
||||||
|
cleartext-exposure gap; it does not move the storage path.
|
||||||
|
- No theta-suite policy change required — `secret/sso-manager/conf` was already
|
||||||
|
granted to the `sso-broker` policy.
|
||||||
|
|
||||||
|
|
||||||
|
#### ldap-client v1.23.0 — CHANGELOG-only (no code change)
|
||||||
|
|
||||||
|
Adds a `CHANGELOG.md` documenting v1.1.0 (`app_super_admin` / `app_jump_admin`
|
||||||
|
group support in SSSD access filters; the sso/jump-host TLS-validation
|
||||||
|
divergence) and v1.0.0 (initial SSSD LDAP auth release). No source changes vs
|
||||||
|
v1.1.1; the v1.23.0 tag commit only adds this file.
|
||||||
|
|
||||||
|
## [v1.30.1] - 2026-08-01
|
||||||
|
|
||||||
|
Prerequisite release for the SSO Manager plugin system (shipped in
|
||||||
|
sso-manager-node v1.17.0). Grants the `sso-broker` OpenBao policy access to the
|
||||||
|
new per-instance plugin secrets namespace so the SSO can store plugin secrets in
|
||||||
|
OpenBao instead of `sso-secrets.js`.
|
||||||
|
|
||||||
|
### Changed (theta-suite orchestration)
|
||||||
|
- **`setup.sh`**: added `secret/data/plugins/*` (CRUD+list) and
|
||||||
|
`secret/metadata/plugins/*` (list/read/delete) to the `sso-broker` policy
|
||||||
|
HCL. `ensure_policy sso-broker` is idempotent, so re-running `./setup.sh`
|
||||||
|
immediately grants the existing `SSO_VAULT_TOKEN` access to `secret/plugins/*`
|
||||||
|
(policies are evaluated live; the token keeps its id). The SSO side fails-soft
|
||||||
|
with a clear error if this grant is absent.
|
||||||
|
- **Docs**: `docs/secrets.md` (new "Plugin secrets" section + `sso-broker`
|
||||||
|
policy row) and `docs/architecture.md` (sso-manager access row) now list
|
||||||
|
`secret/plugins/*`.
|
||||||
|
|
||||||
|
> The plugin system itself (configurable plugin instances, load/unload, UI/API,
|
||||||
|
> multi-copy, secrets in OpenBao) is in sso-manager-node v1.17.0; theta-suite
|
||||||
|
> will bump its submodule gitlink to that release next.
|
||||||
|
|
||||||
|
## [v1.30.0] - 2026-08-01
|
||||||
|
|
||||||
|
The project is renamed **theta-env → theta-suite** — it has grown from a
|
||||||
|
docker-compose wiring two projects into an integrated suite of four
|
||||||
|
applications around a shared OpenBao secrets store, and the name should reflect
|
||||||
|
that. The GitHub repository is renamed `theta42/theta-env` →
|
||||||
|
`theta42/theta-suite` (old URLs redirect), and the docs site moves to
|
||||||
|
`https://theta42.github.io/theta-suite/`.
|
||||||
|
|
||||||
|
### Changed (theta-suite orchestration)
|
||||||
|
- **Renamed theta-env → theta-suite** across the superproject: `docs/_config.yml`
|
||||||
|
(`title` + `baseurl: /theta-suite` + repo URLs), `README.md`, `setup.sh`
|
||||||
|
(incl. the `THETA_SUITE_REEXECED` self-update sentinel), `docker-compose.yml`,
|
||||||
|
`bootstrap/bootstrap.js`, `.github/workflows/lint.yml`, `config.example/*`,
|
||||||
|
`docs/robots.txt`, all docs pages, and this changelog.
|
||||||
|
- **Compose project name note:** docker compose derives the project name from
|
||||||
|
the clone directory, so named volumes follow it (`<project>_openbao-data`).
|
||||||
|
A fresh `git clone` of `theta-suite` uses the `theta-suite` project name; an
|
||||||
|
existing deployment that keeps its `theta-env` directory keeps its
|
||||||
|
`theta-env_*` volumes — no data migration is required, just don't mix the two.
|
||||||
|
- **Docs site baseurl** is now `/theta-suite`, matching the renamed repo's
|
||||||
|
GitHub Pages URL.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **`architecture.md` rewritten.** Replaced the outdated "The two containers"
|
||||||
|
/ "The three repos" framing with the actual topology — four always-on
|
||||||
|
services (`openbao`, `sso-manager`, `proxy`, `jump-host`) plus the
|
||||||
|
`ldap-client` host-enrollment tool — a real diagram, a full **Secrets
|
||||||
|
(OpenBao)** section (central store, scoped per-app policies/tokens, the
|
||||||
|
`@simpleworkjs/bao-conf` boot overlay, per-user KV, external-app minting),
|
||||||
|
and an OpenBao-aware "how config reaches the apps". Removed the
|
||||||
|
LDAP-"legacy apps" wording (direct LDAP binds are first-class: Linux hosts
|
||||||
|
PAM/SSSD, sudo, SSH keys).
|
||||||
|
- **`index.md`** — integrated-suite framing; added **Central secrets (OpenBao)**
|
||||||
|
and **ldap-client** to "What you get" and "Related projects".
|
||||||
|
- **`standalone.md` + `README.md`** — standalone is now framed as an advanced
|
||||||
|
opt-in; the integrated `./setup.sh` stack is the supported path.
|
||||||
|
|
||||||
|
### Submodule bump
|
||||||
|
- **sso-manager-node → v1.16.1** — fixes the **401 on `/conf` and `/vault`** for
|
||||||
|
a logged-in admin. Both view routes 401'd because this app's auth-token is a
|
||||||
|
header set by client JS (localStorage), not a cookie, so `req.user` is
|
||||||
|
undefined on a browser navigation; the routes now render the shell and gate
|
||||||
|
client-side (`app.auth.forceLogin`), with `/api/conf` + `/api/vault` still
|
||||||
|
enforcing auth + OpenBao scope server-side. See the
|
||||||
|
[sso v1.16.1 release](https://github.com/theta42/sso-manager-node/releases/tag/v1.16.1).
|
||||||
|
|
||||||
## [v1.29.0] - 2026-08-01
|
## [v1.29.0] - 2026-08-01
|
||||||
|
|
||||||
Two fixes for a fresh `./setup.sh` install, plus the SSH jump host promoted
|
Two fixes for a fresh `./setup.sh` install, plus the SSH jump host promoted
|
||||||
from an opt-in component to a core part of the stack.
|
from an opt-in component to a core part of the stack.
|
||||||
|
|
||||||
### Fixed (theta-env orchestration)
|
### Fixed (theta-suite orchestration)
|
||||||
- **`setup.sh`** — fresh installs aborted silently right after `Minting
|
- **`setup.sh`** — fresh installs aborted silently right after `Minting
|
||||||
per-app OpenBao tokens`. The `env_get` helper's `grep | cut` pipeline returns
|
per-app OpenBao tokens`. The `env_get` helper's `grep | cut` pipeline returns
|
||||||
non-zero under `set -euo pipefail` when `.env` exists (it's created earlier
|
non-zero under `set -euo pipefail` when `.env` exists (it's created earlier
|
||||||
@@ -26,7 +481,7 @@ from an opt-in component to a core part of the stack.
|
|||||||
- **`setup.sh`** — `JUMP_VAULT_TOKEN` is now always minted (jump host is core;
|
- **`setup.sh`** — `JUMP_VAULT_TOKEN` is now always minted (jump host is core;
|
||||||
the mint was already unconditional, this just documents it).
|
the mint was already unconditional, this just documents it).
|
||||||
|
|
||||||
### Changed (theta-env orchestration)
|
### Changed (theta-suite orchestration)
|
||||||
- **jump host is no longer optional** — it is built + started on every run,
|
- **jump host is no longer optional** — it is built + started on every run,
|
||||||
with no `CFG_JUMP_HOST_ENABLED` flag.
|
with no `CFG_JUMP_HOST_ENABLED` flag.
|
||||||
- `docker-compose.yml`: removed `profiles: ["jump-host"]` from the
|
- `docker-compose.yml`: removed `profiles: ["jump-host"]` from the
|
||||||
@@ -53,7 +508,7 @@ apps get scoped `secret/apps/<app>/*` access. `setup.sh` mints the policies
|
|||||||
and scoped tokens, `bootstrap.js` writes generated creds into OpenBao, and a
|
and scoped tokens, `bootstrap.js` writes generated creds into OpenBao, and a
|
||||||
new `docs/secrets.md` documents the architecture.
|
new `docs/secrets.md` documents the architecture.
|
||||||
|
|
||||||
### Changed (theta-env orchestration)
|
### Changed (theta-suite orchestration)
|
||||||
- **`setup.sh`** — after the KV-v2 enable, a new idempotent block writes four
|
- **`setup.sh`** — after the KV-v2 enable, a new idempotent block writes four
|
||||||
OpenBao policies (`sso-broker`, `sso-admin`, `proxy`, `jump-host`) via
|
OpenBao policies (`sso-broker`, `sso-admin`, `proxy`, `jump-host`) via
|
||||||
heredocs, a `sso-broker` token role (`allowed_policies_glob` `user-*`/`app-*`,
|
heredocs, a `sso-broker` token role (`allowed_policies_glob` `user-*`/`app-*`,
|
||||||
@@ -146,7 +601,7 @@ and exposes a fixed, role-scoped personal-secrets UI.
|
|||||||
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
|
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
|
||||||
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
|
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
|
||||||
+ continue from `CONF_SECRETS`), matching the documented contract. The
|
+ continue from `CONF_SECRETS`), matching the documented contract. The
|
||||||
theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
|
theta-suite stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
|
||||||
|
|
||||||
##### v1.13.0 — Changed
|
##### v1.13.0 — Changed
|
||||||
- **Secrets now load from OpenBao at boot** via
|
- **Secrets now load from OpenBao at boot** via
|
||||||
@@ -171,7 +626,7 @@ and exposes a fixed, role-scoped personal-secrets UI.
|
|||||||
jump host exit at boot in any deployment without an OpenBao sidecar
|
jump host exit at boot in any deployment without an OpenBao sidecar
|
||||||
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
|
(standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing
|
||||||
token (warn + continue from `CONF_SECRETS`), matching the documented
|
token (warn + continue from `CONF_SECRETS`), matching the documented
|
||||||
contract. The theta-env stack is unaffected (it always sets a scoped
|
contract. The theta-suite stack is unaffected (it always sets a scoped
|
||||||
`VAULT_TOKEN`).
|
`VAULT_TOKEN`).
|
||||||
|
|
||||||
##### v1.14.0 — Changed
|
##### v1.14.0 — Changed
|
||||||
@@ -542,7 +997,7 @@ No `setup.sh` or compose change.
|
|||||||
## [1.10.0] - 2026-07-27
|
## [1.10.0] - 2026-07-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **`bootstrap/bootstrap.js`'s jump-secrets.js template now points jump-host at `ldaps://sso-manager:636`**, not `ldap://sso-manager:389`. The plain-port URL combined with jump-host's `tlsOptions` made `ldapts` attempt implicit TLS against a port serving plaintext LDAP — slapd dropped every connection before any LDAP message parsed, so SSH password login failed for every account, with any password, indistinguishable from a wrong credential. Root-caused by standing up a local jump-host, editing its config, and calling `getUser`/`checkPassword` directly inside the container. **Existing deployments must edit `./config/jump-secrets.js` themselves** (this template only affects fresh bootstraps) — see theta42/theta-env#99. Companion defensive fix: [simpleworkjs/ldap v1.0.2](https://github.com/simpleworkjs/ldap/releases/tag/v1.0.2) now rejects this `ldap://` + `tlsOptions` combination outright.
|
- **`bootstrap/bootstrap.js`'s jump-secrets.js template now points jump-host at `ldaps://sso-manager:636`**, not `ldap://sso-manager:389`. The plain-port URL combined with jump-host's `tlsOptions` made `ldapts` attempt implicit TLS against a port serving plaintext LDAP — slapd dropped every connection before any LDAP message parsed, so SSH password login failed for every account, with any password, indistinguishable from a wrong credential. Root-caused by standing up a local jump-host, editing its config, and calling `getUser`/`checkPassword` directly inside the container. **Existing deployments must edit `./config/jump-secrets.js` themselves** (this template only affects fresh bootstraps) — see theta42/theta-suite#99. Companion defensive fix: [simpleworkjs/ldap v1.0.2](https://github.com/simpleworkjs/ldap/releases/tag/v1.0.2) now rejects this `ldap://` + `tlsOptions` combination outright.
|
||||||
|
|
||||||
### Bumped
|
### Bumped
|
||||||
- jump-host -> [v1.7.0](https://github.com/theta42/jump-host/releases/tag/v1.7.0) — adds self-service API tokens (create/list/rotate/revoke from its dashboard); jump-host previously had none.
|
- jump-host -> [v1.7.0](https://github.com/theta42/jump-host/releases/tag/v1.7.0) — adds self-service API tokens (create/list/rotate/revoke from its dashboard); jump-host previously had none.
|
||||||
@@ -629,14 +1084,14 @@ Jump-host gains **standalone mode**: it can now run with no LDAP directory and
|
|||||||
no SSO Manager at all, storing users and hosts itself via
|
no SSO Manager at all, storing users and hosts itself via
|
||||||
`@simpleworkjs/orm` (Sequelize; SQLite by default, any Sequelize-supported
|
`@simpleworkjs/orm` (Sequelize; SQLite by default, any Sequelize-supported
|
||||||
dialect). This is an app-internal capability, opt-in via
|
dialect). This is an app-internal capability, opt-in via
|
||||||
`standalone.enabled` in jump-host's own config — the bundled theta-env stack
|
`standalone.enabled` in jump-host's own config — the bundled theta-suite stack
|
||||||
is unaffected and continues to wire jump-host to the shared LDAP directory
|
is unaffected and continues to wire jump-host to the shared LDAP directory
|
||||||
and SSO Manager as before. Two bugs were also fixed in jump-host's SSH
|
and SSO Manager as before. Two bugs were also fixed in jump-host's SSH
|
||||||
server: an ephemeral listen port (`0`) was silently overridden back to the
|
server: an ephemeral listen port (`0`) was silently overridden back to the
|
||||||
default, and session listeners could miss a client's immediate `exec`/`shell`
|
default, and session listeners could miss a client's immediate `exec`/`shell`
|
||||||
request.
|
request.
|
||||||
|
|
||||||
No `setup.sh`, compose, or config change on the theta-env side.
|
No `setup.sh`, compose, or config change on the theta-suite side.
|
||||||
|
|
||||||
## [1.5.0] - 2026-07-26
|
## [1.5.0] - 2026-07-26
|
||||||
|
|
||||||
@@ -690,7 +1145,7 @@ sso-manager-node 1.5.0:
|
|||||||
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
|
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
|
||||||
|
|
||||||
### Verified
|
### Verified
|
||||||
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
- Browser-verified against a full theta-suite stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
proxy 1.4.0:
|
proxy 1.4.0:
|
||||||
|
|
||||||
@@ -713,7 +1168,7 @@ proxy 1.4.0:
|
|||||||
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
|
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
|
||||||
|
|
||||||
### Verified
|
### Verified
|
||||||
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
- Browser-verified against a full theta-suite stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
jump-host 1.3.0:
|
jump-host 1.3.0:
|
||||||
|
|
||||||
@@ -736,7 +1191,7 @@ jump-host 1.3.0:
|
|||||||
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
|
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
|
||||||
|
|
||||||
### Verified
|
### Verified
|
||||||
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
- Browser-verified against a full theta-suite stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
## [1.4.0] - 2026-07-25
|
## [1.4.0] - 2026-07-25
|
||||||
|
|
||||||
@@ -834,12 +1289,12 @@ sso-manager-node 1.3.2:
|
|||||||
sso-manager-node 1.3.1:
|
sso-manager-node 1.3.1:
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
|
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-suite seed), the automatic-registration story (theta-suite stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
|
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
|
||||||
|
|
||||||
### theta-env own changes
|
### theta-suite own changes
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `CFG_SITE_NAME` in `setup.env` (right below `CFG_DOMAIN`, default `local`): names the SSO directory site the stack registers itself under — slug `site_<name>`, matching the `parentSlug` convention ldap-client-joined Linux hosts use, so they land under the same site.
|
- `CFG_SITE_NAME` in `setup.env` (right below `CFG_DOMAIN`, default `local`): names the SSO directory site the stack registers itself under — slug `site_<name>`, matching the `parentSlug` convention ldap-client-joined Linux hosts use, so they land under the same site.
|
||||||
@@ -915,7 +1370,7 @@ sso-manager-node:
|
|||||||
### Changed
|
### Changed
|
||||||
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
|
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
|
||||||
|
|
||||||
### theta-env own changes
|
### theta-suite own changes
|
||||||
- Refreshed `docs/images/sso-dashboard.png` and `docs/images/proxy-hosts.png` to match the submodules' updated screenshots.
|
- Refreshed `docs/images/sso-dashboard.png` and `docs/images/proxy-hosts.png` to match the submodules' updated screenshots.
|
||||||
|
|
||||||
## [1.1.20] - 2026-07-20
|
## [1.1.20] - 2026-07-20
|
||||||
@@ -945,7 +1400,7 @@ sso-manager-node:
|
|||||||
- `routes/index.js` now derives the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host.
|
- `routes/index.js` now derives the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host.
|
||||||
- `docs/configuration.md`, `docs/ldap.md`, `DEPLOYMENT.md`, and `secrets.js.example` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
|
- `docs/configuration.md`, `docs/ldap.md`, `DEPLOYMENT.md`, and `secrets.js.example` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
|
||||||
|
|
||||||
### theta-env own changes
|
### theta-suite own changes
|
||||||
- `setup.env.example` adds optional `CFG_LDAPS_HOST` for the internal LDAPS hostname.
|
- `setup.env.example` adds optional `CFG_LDAPS_HOST` for the internal LDAPS hostname.
|
||||||
- `setup.sh` passes `CFG_LDAPS_HOST` into the generated `./config/sso-secrets.js` as `ldap.ldapsHost`.
|
- `setup.sh` passes `CFG_LDAPS_HOST` into the generated `./config/sso-secrets.js` as `ldap.ldapsHost`.
|
||||||
- `config.example/sso-secrets.js.example` documents `ldap.ldapsHost` / `ldap.ldapsPort`.
|
- `config.example/sso-secrets.js.example` documents `ldap.ldapsHost` / `ldap.ldapsPort`.
|
||||||
@@ -989,7 +1444,7 @@ sso-manager-node:
|
|||||||
### Fixed
|
### Fixed
|
||||||
- `models/email.js`: fixed from-address template rendering bug.
|
- `models/email.js`: fixed from-address template rendering bug.
|
||||||
|
|
||||||
### theta-env own changes
|
### theta-suite own changes
|
||||||
- `CHANGELOG.md` now embeds the full app-level release notes for each submodule bump, not just links.
|
- `CHANGELOG.md` now embeds the full app-level release notes for each submodule bump, not just links.
|
||||||
- `.env.example` no longer ships realistic-looking default passwords; values are clearly placeholders.
|
- `.env.example` no longer ships realistic-looking default passwords; values are clearly placeholders.
|
||||||
- `config.example/*.js.example` comments now describe the actual `CONF_SECRETS` env-var loading mechanism.
|
- `config.example/*.js.example` comments now describe the actual `CONF_SECRETS` env-var loading mechanism.
|
||||||
@@ -1129,10 +1584,10 @@ sso-manager-node:
|
|||||||
- proxy -> [v1.1.7](https://github.com/theta42/proxy/releases/tag/v1.1.7)
|
- proxy -> [v1.1.7](https://github.com/theta42/proxy/releases/tag/v1.1.7)
|
||||||
- sso-manager-node -> [v1.1.6](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.6)
|
- sso-manager-node -> [v1.1.6](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.6)
|
||||||
|
|
||||||
Both: redesigned the GitHub Pages docs site to match each app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`), and mobile-responsive layout. theta-env's own docs site got the same treatment in this release too (see below).
|
Both: redesigned the GitHub Pages docs site to match each app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`), and mobile-responsive layout. theta-suite's own docs site got the same treatment in this release too (see below).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- theta-env's own docs site redesigned the same way -- dark navbar/footer using the shared theta42 logo (this repo has no app UI of its own), cross-page nav, SEO, mobile-responsive. `docs/index.md`'s "More docs" section removed (redundant with the new nav).
|
- theta-suite's own docs site redesigned the same way -- dark navbar/footer using the shared theta42 logo (this repo has no app UI of its own), cross-page nav, SEO, mobile-responsive. `docs/index.md`'s "More docs" section removed (redundant with the new nav).
|
||||||
- Added `docs/_site` to `.gitignore` (missing entirely before).
|
- Added `docs/_site` to `.gitignore` (missing entirely before).
|
||||||
|
|
||||||
## [1.1.6] - 2026-07-16
|
## [1.1.6] - 2026-07-16
|
||||||
@@ -1165,7 +1620,7 @@ Both: bumped `jq-repeat` 2.0.1 -> 2.1.0. proxy fixed real breakage from the remo
|
|||||||
## [1.1.3] - 2026-07-16
|
## [1.1.3] - 2026-07-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `CHANGELOG.md` (this file). Closes [#43](https://github.com/theta42/theta-env/issues/43).
|
- `CHANGELOG.md` (this file). Closes [#43](https://github.com/theta42/theta-suite/issues/43).
|
||||||
|
|
||||||
### Bumped
|
### Bumped
|
||||||
- proxy -> [v1.1.3](https://github.com/theta42/proxy/releases/tag/v1.1.3)
|
- proxy -> [v1.1.3](https://github.com/theta42/proxy/releases/tag/v1.1.3)
|
||||||
@@ -1200,23 +1655,32 @@ First tagged release. Establishes the `vX.Y.Z` tag convention going forward.
|
|||||||
- proxy -> [v1.1.0](https://github.com/theta42/proxy/releases/tag/v1.1.0)
|
- proxy -> [v1.1.0](https://github.com/theta42/proxy/releases/tag/v1.1.0)
|
||||||
- sso-manager-node -> [v1.1.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0)
|
- sso-manager-node -> [v1.1.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0)
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/theta-env/compare/v1.4.0...HEAD
|
[Unreleased]: https://github.com/theta42/theta-suite/compare/v1.4.0...HEAD
|
||||||
[1.4.0]: https://github.com/theta42/theta-env/compare/v1.3.7...v1.4.0
|
[1.4.0]: https://github.com/theta42/theta-suite/compare/v1.3.7...v1.4.0
|
||||||
[1.1.17]: https://github.com/theta42/theta-env/compare/v1.1.16...v1.1.17
|
[1.1.17]: https://github.com/theta42/theta-suite/compare/v1.1.16...v1.1.17
|
||||||
[1.1.16]: https://github.com/theta42/theta-env/compare/v1.1.15...v1.1.16
|
[1.1.16]: https://github.com/theta42/theta-suite/compare/v1.1.15...v1.1.16
|
||||||
[1.1.15]: https://github.com/theta42/theta-env/compare/v1.1.14...v1.1.15
|
[1.1.15]: https://github.com/theta42/theta-suite/compare/v1.1.14...v1.1.15
|
||||||
[1.1.14]: https://github.com/theta42/theta-env/compare/v1.1.13...v1.1.14
|
[1.1.14]: https://github.com/theta42/theta-suite/compare/v1.1.13...v1.1.14
|
||||||
[1.1.13]: https://github.com/theta42/theta-env/compare/v1.1.12...v1.1.13
|
[1.1.13]: https://github.com/theta42/theta-suite/compare/v1.1.12...v1.1.13
|
||||||
[1.1.12]: https://github.com/theta42/theta-env/compare/v1.1.11...v1.1.12
|
[1.1.12]: https://github.com/theta42/theta-suite/compare/v1.1.11...v1.1.12
|
||||||
[1.1.11]: https://github.com/theta42/theta-env/compare/v1.1.10...v1.1.11
|
[1.1.11]: https://github.com/theta42/theta-suite/compare/v1.1.10...v1.1.11
|
||||||
[1.1.10]: https://github.com/theta42/theta-env/compare/v1.1.9...v1.1.10
|
[1.1.10]: https://github.com/theta42/theta-suite/compare/v1.1.9...v1.1.10
|
||||||
[1.1.9]: https://github.com/theta42/theta-env/compare/v1.1.8...v1.1.9
|
[1.1.9]: https://github.com/theta42/theta-suite/compare/v1.1.8...v1.1.9
|
||||||
[1.1.8]: https://github.com/theta42/theta-env/compare/v1.1.7...v1.1.8
|
[1.1.8]: https://github.com/theta42/theta-suite/compare/v1.1.7...v1.1.8
|
||||||
[1.1.7]: https://github.com/theta42/theta-env/compare/v1.1.6...v1.1.7
|
[1.1.7]: https://github.com/theta42/theta-suite/compare/v1.1.6...v1.1.7
|
||||||
[1.1.6]: https://github.com/theta42/theta-env/compare/v1.1.5...v1.1.6
|
[1.1.6]: https://github.com/theta42/theta-suite/compare/v1.1.5...v1.1.6
|
||||||
[1.1.5]: https://github.com/theta42/theta-env/compare/v1.1.4...v1.1.5
|
[1.1.5]: https://github.com/theta42/theta-suite/compare/v1.1.4...v1.1.5
|
||||||
[1.1.4]: https://github.com/theta42/theta-env/compare/v1.1.3...v1.1.4
|
[1.1.4]: https://github.com/theta42/theta-suite/compare/v1.1.3...v1.1.4
|
||||||
[1.1.3]: https://github.com/theta42/theta-env/compare/v1.1.2...v1.1.3
|
[1.1.3]: https://github.com/theta42/theta-suite/compare/v1.1.2...v1.1.3
|
||||||
[1.1.2]: https://github.com/theta42/theta-env/compare/v1.1.1...v1.1.2
|
[1.1.2]: https://github.com/theta42/theta-suite/compare/v1.1.1...v1.1.2
|
||||||
[1.1.1]: https://github.com/theta42/theta-env/compare/v1.1.0...v1.1.1
|
[1.1.1]: https://github.com/theta42/theta-suite/compare/v1.1.0...v1.1.1
|
||||||
[1.1.0]: https://github.com/theta42/theta-env/releases/tag/v1.1.0
|
[1.1.0]: https://github.com/theta42/theta-suite/releases/tag/v1.1.0
|
||||||
|
|
||||||
|
## [1.34.4] - 2026-08-02
|
||||||
|
### Changed
|
||||||
|
- Updated `sso-manager-node` submodule to `v1.19.6` to pull in a fix for the Vault API 403 error on the Secrets List.
|
||||||
|
|
||||||
|
## [1.34.5] - 2026-08-02
|
||||||
|
### Added
|
||||||
|
- Automatically build and install `theta-agent` on the host system as a systemd service during `setup.sh`.
|
||||||
|
- Added `CFG_CREATE_ALL_HTTP` option to `setup.env` to create all default proxy host entries with `forcessl=false`.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# theta-env
|
# theta-suite
|
||||||
|
|
||||||
The whole theta42 identity + access stack in one repo, brought up with a single
|
The whole theta42 identity + access stack in one repo, brought up with a single
|
||||||
command — for home labs and small businesses.
|
command — for home labs and small businesses.
|
||||||
|
|
||||||
It wires together two projects that already work on their own:
|
It composes four applications around a shared [OpenBao](https://openbao.org/)
|
||||||
|
secrets store, brought up with one command:
|
||||||
|
|
||||||
- **[SSO Manager](https://github.com/theta42/sso-manager-node)** — an OIDC
|
- **[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
|
provider with a built-in LDAP directory (OpenLDAP) and a web UI for managing
|
||||||
@@ -11,12 +12,15 @@ It wires together two projects that already work on their own:
|
|||||||
- **[theta42/proxy](https://github.com/theta42/proxy)** — an OIDC-protected
|
- **[theta42/proxy](https://github.com/theta42/proxy)** — an OIDC-protected
|
||||||
reverse proxy (OpenResty) that puts any of your apps behind SSO login and can
|
reverse proxy (OpenResty) that puts any of your apps behind SSO login and can
|
||||||
look users up directly in LDAP.
|
look users up directly in LDAP.
|
||||||
|
- **[Jump Host](https://github.com/theta42/jump-host)** — directory-driven SSH
|
||||||
|
access to your machines through one public entry point.
|
||||||
|
- **[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);
|
All four load their secrets from OpenBao at boot; `setup.sh` automates the
|
||||||
this repo just composes them and automates the first-run glue so they find each
|
first-run glue so they find each other and the secrets store.
|
||||||
other.
|
|
||||||
|
|
||||||
**Documentation:** [https://theta42.github.io/theta-env/](https://theta42.github.io/theta-env/)
|
**Documentation:** [https://theta42.github.io/theta-suite/](https://theta42.github.io/theta-suite/)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -125,26 +129,29 @@ see browser warnings.)
|
|||||||
|
|
||||||
Optional extra ports (only if you need them):
|
Optional extra ports (only if you need them):
|
||||||
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
|
- **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
|
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
|
||||||
directly over the network. The proxy itself reaches LDAP over the internal
|
enrollment (`setup.sh` → ldap-client) configures its sssd against
|
||||||
Docker network, so you do **not** need to expose 636 for the stack to work.
|
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
|
||||||
**Do not forward 636 to the public internet.** If you need LAN clients to bind
|
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
|
||||||
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
|
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
|
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.
|
SAN. The default shows the public SSO hostname, which implies a public route.
|
||||||
|
|
||||||
### 4. Docker + Docker Compose
|
### 4. Docker + Docker Compose
|
||||||
|
|
||||||
Any recent Docker with Compose — the v2 plugin (`docker compose`) or the v1
|
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.
|
||||||
standalone (`docker-compose`) both work.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recursive https://github.com/theta42/theta-env.git
|
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||||
cd theta-env
|
cd theta-suite
|
||||||
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
|
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
|
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
|
||||||
```
|
```
|
||||||
@@ -233,9 +240,10 @@ override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See
|
|||||||
- **Proxy mgmt UI**: `https://<PROXY_HOST>` — add the Host records you want to
|
- **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
|
protect with OIDC. (First-run fallback: `http://<host>:3000`, reachable on the
|
||||||
LAN by default.)
|
LAN by default.)
|
||||||
- **Direct LDAP for legacy apps**: bind to `ldaps://<host>:636` as
|
- **Direct LDAP for LDAP-native clients and Linux hosts**: bind to
|
||||||
`cn=admin,<base>` (admin) or `cn=ldapclient,ou=people,<base>` (read-only
|
`ldaps://<host>:636` as `cn=admin,<base>` (admin) or
|
||||||
service account the bootstrap created). Use LDAPS, not plain LDAP.
|
`cn=ldapclient,ou=people,<base>` (read-only service account the bootstrap
|
||||||
|
created). Use LDAPS, not plain LDAP.
|
||||||
|
|
||||||
### API tokens (personal access tokens)
|
### API tokens (personal access tokens)
|
||||||
|
|
||||||
@@ -346,7 +354,7 @@ docker compose cp proxy:/data/dump.rdb proxy.rdb
|
|||||||
# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative)
|
# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative)
|
||||||
cp -a ./config config-backup && chmod 700 config-backup
|
cp -a ./config config-backup && chmod 700 config-backup
|
||||||
# OpenBao (the authoritative secret store — back up its data volume)
|
# OpenBao (the authoritative secret store — back up its data volume)
|
||||||
docker run --rm -v theta-env_openbao-data:/data -v "$PWD":/backup alpine \
|
docker run --rm -v theta-suite_openbao-data:/data -v "$PWD":/backup alpine \
|
||||||
tar czf /backup/openbao-data.tgz -C /data .
|
tar czf /backup/openbao-data.tgz -C /data .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -398,30 +406,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
|
## How the first-run wiring works
|
||||||
@@ -486,7 +470,7 @@ exactly in the bootstrap) so the SSO can verify them on bind.
|
|||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
```
|
```
|
||||||
theta-env/
|
theta-suite/
|
||||||
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN
|
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN
|
||||||
├── config.example/ # committed annotated config templates (copy to ./config/)
|
├── 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 on one bridge net
|
||||||
@@ -507,7 +491,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
|
lock to the pinned commits (offline rebuild, or a deliberate pin), run
|
||||||
`SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
|
`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` —
|
(and each submodule's own `CHANGELOG.md` —
|
||||||
[proxy](https://github.com/theta42/proxy/blob/master/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)
|
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/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:
|
* proxy into a (fresh or existing) SSO Manager. Invoked by setup.sh:
|
||||||
*
|
*
|
||||||
* docker compose exec sso-manager node /bootstrap/bootstrap.js
|
* docker compose exec sso-manager node /bootstrap/bootstrap.js
|
||||||
@@ -89,7 +89,13 @@ const CLIENT_NAME = 'theta-proxy';
|
|||||||
|
|
||||||
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
|
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
|
||||||
const SVC_DN = `cn=ldapclient,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 log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
|
||||||
const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
|
const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
|
||||||
@@ -172,31 +178,88 @@ function ldapModify(ldif) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 1. LDAP service account for the proxy ───────────────────────────────────
|
// ── 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() {
|
function ensureServiceAccount() {
|
||||||
const pw = hashPasswordSSHA512(SVC_PASS);
|
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)) {
|
if (entryExists(SVC_DN)) {
|
||||||
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
|
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
|
||||||
const r = ldapModify([
|
// 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}`,
|
`dn: ${SVC_DN}`,
|
||||||
'changetype: modify',
|
'changetype: modify',
|
||||||
'replace: userPassword',
|
'replace: userPassword',
|
||||||
`userPassword: ${pw}`,
|
`userPassword: ${pw}`,
|
||||||
'',
|
'',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
|
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
|
||||||
return;
|
} 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}`);
|
// Mark it as a service account (the Users UI's service-account signal).
|
||||||
const r = ldapAdd([
|
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
|
||||||
`dn: ${SVC_DN}`,
|
const rm = ldapModify([
|
||||||
'objectClass: organizationalRole',
|
`dn: ${gdn}`,
|
||||||
'objectClass: simpleSecurityObject',
|
'changetype: modify',
|
||||||
'objectClass: top',
|
'add: member',
|
||||||
'cn: ldapclient',
|
`member: ${SVC_DN}`,
|
||||||
`userPassword: ${pw}`,
|
|
||||||
'',
|
'',
|
||||||
].join('\n'));
|
].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 ─────────────────────────────────────────────────────
|
// ── 2. First admin user ─────────────────────────────────────────────────────
|
||||||
@@ -275,7 +338,7 @@ async function listClients(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createClient(token, opts) {
|
async function createClient(token, opts) {
|
||||||
const o = opts || { name: CLIENT_NAME, description: 'theta-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
|
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
|
||||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
|
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||||
@@ -423,15 +486,46 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
|||||||
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
|
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
|
||||||
subType: 'linux',
|
subType: 'linux',
|
||||||
ip: HOST_FACTS.ip,
|
ip: HOST_FACTS.ip,
|
||||||
|
address: HOST_FACTS.ip,
|
||||||
macAddress: HOST_FACTS.mac,
|
macAddress: HOST_FACTS.mac,
|
||||||
os: HOST_FACTS.os,
|
os: HOST_FACTS.os,
|
||||||
kernel: HOST_FACTS.kernel,
|
kernel: HOST_FACTS.kernel,
|
||||||
|
sshPort: 22,
|
||||||
|
managed: true,
|
||||||
}, ['stack-host']);
|
}, ['stack-host']);
|
||||||
|
|
||||||
|
// theta-proxy and theta-jump are first-class managed host resources (their
|
||||||
|
// names match the OAuth client identities the proxy/jump apps use). They
|
||||||
|
// appear as hosts in the Directory; the per-app services below still carry
|
||||||
|
// the OAuth-client + reachability detail.
|
||||||
|
const jumpHostAddr = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||||
|
await ensure('host', 'theta-proxy', 'host_theta-proxy', site.id, {
|
||||||
|
subType: 'linux',
|
||||||
|
address: `https://${PROXY_HOST}`,
|
||||||
|
port: 3000,
|
||||||
|
gitRepo: 'https://github.com/theta42/proxy',
|
||||||
|
icon: 'mdi:server-network',
|
||||||
|
tagline: 'Reverse proxy and API gateway (node management UI).',
|
||||||
|
managed: true,
|
||||||
|
});
|
||||||
|
await ensure('host', 'theta-jump', 'host_theta-jump', site.id, {
|
||||||
|
subType: 'ssh',
|
||||||
|
address: jumpHostAddr ? `https://${jumpHostAddr}` : '',
|
||||||
|
port: 3002,
|
||||||
|
gitRepo: 'https://github.com/theta42/jump-host',
|
||||||
|
icon: 'mdi:ssh',
|
||||||
|
tagline: 'Secure SSH jump host.',
|
||||||
|
managed: true,
|
||||||
|
});
|
||||||
|
|
||||||
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
|
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
|
||||||
address: `https://${SSO_HOST}`,
|
address: `https://${SSO_HOST}`,
|
||||||
port: 3001,
|
port: 3001,
|
||||||
gitRepo: 'https://github.com/theta42/sso-manager-node',
|
gitRepo: 'https://github.com/theta42/sso-manager-node',
|
||||||
subType: 'web',
|
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
|
// Proxy = the node management UI; OpenResty = the data plane every hostname
|
||||||
// in the stack actually flows through (80/443). Two faces, two entries.
|
// in the stack actually flows through (80/443). Two faces, two entries.
|
||||||
@@ -440,6 +534,9 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
gitRepo: 'https://github.com/theta42/proxy',
|
gitRepo: 'https://github.com/theta42/proxy',
|
||||||
subType: 'web',
|
subType: 'web',
|
||||||
|
icon: 'mdi:server-network',
|
||||||
|
tagline: 'Reverse proxy and API gateway.',
|
||||||
|
requestable: false,
|
||||||
});
|
});
|
||||||
// OpenLDAP is independently consumed — Linux hosts authenticate against it
|
// OpenLDAP is independently consumed — Linux hosts authenticate against it
|
||||||
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
|
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
|
||||||
@@ -451,8 +548,12 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
|||||||
address: `ldaps://${LDAPS_HOST}:636`,
|
address: `ldaps://${LDAPS_HOST}:636`,
|
||||||
port: 389,
|
port: 389,
|
||||||
externalPort: 636,
|
externalPort: 636,
|
||||||
|
portMappings: [{ proto: 'tcp', external: 636, internal: 389, comment: 'LDAPS' }],
|
||||||
gitRepo: 'https://github.com/theta42/sso-manager-node',
|
gitRepo: 'https://github.com/theta42/sso-manager-node',
|
||||||
subType: 'openldap',
|
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 address: OpenResty fronts every host under the domain (same
|
||||||
// */** wildcard convention the proxy's Host records use). Its config lives
|
// */** wildcard convention the proxy's Host records use). Its config lives
|
||||||
@@ -462,6 +563,9 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
|||||||
port: 443,
|
port: 443,
|
||||||
gitRepo: 'https://github.com/theta42/proxy',
|
gitRepo: 'https://github.com/theta42/proxy',
|
||||||
subType: 'openresty',
|
subType: 'openresty',
|
||||||
|
icon: 'mdi:router-network',
|
||||||
|
tagline: 'Data plane.',
|
||||||
|
requestable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSH jump host service (core component — always registered).
|
// SSH jump host service (core component — always registered).
|
||||||
@@ -473,6 +577,9 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
|||||||
port: 3002,
|
port: 3002,
|
||||||
gitRepo: 'https://github.com/theta42/jump-host',
|
gitRepo: 'https://github.com/theta42/jump-host',
|
||||||
subType: 'ssh',
|
subType: 'ssh',
|
||||||
|
icon: 'mdi:ssh',
|
||||||
|
tagline: 'Secure SSH jump host.',
|
||||||
|
requestable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +600,59 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
|||||||
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
|
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' },
|
||||||
|
});
|
||||||
|
} 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
|
// 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
|
// (which reads that file) can use them. Only the clientId/clientSecret lines
|
||||||
// are touched; the rest of the file (operator edits, comments) is preserved.
|
// are touched; the rest of the file (operator edits, comments) is preserved.
|
||||||
@@ -543,7 +703,7 @@ async function mintApiToken(token, name) {
|
|||||||
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
|
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, description: 'theta-env jump host (auto-registered)' }),
|
body: JSON.stringify({ name, description: 'theta-suite jump host (auto-registered)' }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
|
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -568,12 +728,11 @@ function writeJumpSecrets(apiToken, oidc, localAdminPass) {
|
|||||||
const siteName = (sso.stack && sso.stack.siteName) || 'local';
|
const siteName = (sso.stack && sso.stack.siteName) || 'local';
|
||||||
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
|
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
|
||||||
const body = `'use strict';
|
const body = `'use strict';
|
||||||
// Generated by theta-env bootstrap. The jump host reads this via
|
// Generated by theta-suite bootstrap. The jump host reads this via
|
||||||
// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the
|
// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the
|
||||||
// sshPublicKey attribute (key injection); for a hardened deployment use a
|
// sshPublicKey attribute (key injection); for a hardened deployment use a
|
||||||
// scoped account with an sshPublicKey write-ACL instead (see jump-host README).
|
// scoped account with an sshPublicKey write-ACL instead (see jump-host README).
|
||||||
module.exports = {
|
module.exports = {
|
||||||
\tname: ${JSON.stringify(sso.name || 'SSO Manager')},
|
|
||||||
\tldap: {
|
\tldap: {
|
||||||
\t\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always
|
\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// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts
|
||||||
@@ -653,7 +812,7 @@ async function provisionJumpHost(token) {
|
|||||||
} else {
|
} else {
|
||||||
oidc = await createClient(token, {
|
oidc = await createClient(token, {
|
||||||
name: JUMP_CLIENT_NAME,
|
name: JUMP_CLIENT_NAME,
|
||||||
description: 'theta-env jump host web UI (auto-registered)',
|
description: 'theta-suite jump host web UI (auto-registered)',
|
||||||
redirect_uris: [JUMP_REDIRECT_URI],
|
redirect_uris: [JUMP_REDIRECT_URI],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -743,6 +902,15 @@ async function provisionJumpHost(token) {
|
|||||||
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
|
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.');
|
log('Done.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use strict';
|
'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.
|
// ./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
|
// `./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
|
// bootstrap writes the OAuth client clientId/clientSecret back into it; this
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use strict';
|
'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.
|
// ./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
|
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
|
||||||
// documents the shape for manual editing / reference.
|
// documents the shape for manual editing / reference.
|
||||||
|
|||||||
@@ -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
|
# 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,
|
# reach the SSO internally (http://sso-manager:3001 for token/userinfo,
|
||||||
@@ -52,12 +52,16 @@ services:
|
|||||||
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
|
# 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>.
|
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
|
||||||
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
|
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
|
||||||
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
|
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
|
||||||
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
|
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
|
||||||
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
|
# against ldap://localhost and ldaps://localhost, and the LDAP server is
|
||||||
# in sso-secrets.js) and do NOT forward 636 to the public internet.
|
# co-located on this host, so BOTH ports must be reachable from the host
|
||||||
- "${LDAPS_PORT:-636}:636"
|
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
|
||||||
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
|
# 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:
|
environment:
|
||||||
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
|
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
|
||||||
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
|
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
|
||||||
@@ -238,6 +242,42 @@ services:
|
|||||||
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
|
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
|
||||||
- ./config/ldap-ca.crt:/config/ldap-ca.crt: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:
|
openbao:
|
||||||
image: quay.io/openbao/openbao:latest
|
image: quay.io/openbao/openbao:latest
|
||||||
container_name: openbao
|
container_name: openbao
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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.
|
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses.
|
||||||
url: "https://theta42.github.io"
|
url: "https://theta42.github.io"
|
||||||
baseurl: "/theta-env"
|
baseurl: "/theta-suite"
|
||||||
logo: /assets/img/theta42.svg
|
logo: /assets/img/theta42.svg
|
||||||
lang: en_US
|
lang: en_US
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ plugins:
|
|||||||
- jekyll-sitemap
|
- jekyll-sitemap
|
||||||
|
|
||||||
github:
|
github:
|
||||||
repository_url: https://github.com/theta42/theta-env
|
repository_url: https://github.com/theta42/theta-suite
|
||||||
zip_url: https://github.com/theta42/theta-env/archive/refs/heads/master.zip
|
zip_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.zip
|
||||||
tar_url: https://github.com/theta42/theta-env/archive/refs/heads/master.tar.gz
|
tar_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.tar.gz
|
||||||
repository_name: theta42/theta-env
|
repository_name: theta42/theta-suite
|
||||||
|
|
||||||
nav:
|
nav:
|
||||||
- title: Home
|
- title: Home
|
||||||
@@ -28,11 +28,17 @@ nav:
|
|||||||
- title: Secrets
|
- title: Secrets
|
||||||
page: /secrets.html
|
page: /secrets.html
|
||||||
icon: fa-key
|
icon: fa-key
|
||||||
- title: Standalone
|
- title: SSO Manager
|
||||||
page: /standalone.html
|
page: /sso/
|
||||||
icon: fa-puzzle-piece
|
icon: fa-users
|
||||||
|
- title: Proxy
|
||||||
|
page: /proxy/
|
||||||
|
icon: fa-shield-halved
|
||||||
|
- title: Jump Host
|
||||||
|
page: /jump-host/
|
||||||
|
icon: fa-terminal
|
||||||
- title: Changelog
|
- 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
|
icon: fa-list
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -1,77 +1,131 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Architecture
|
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 composes the SSO Manager, proxy, jump host, and ldap-client around a shared OpenBao secrets store — the OIDC/LDAP/secrets wiring setup.sh generates from one domain.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
theta-env is a **composition** repo: it builds the two existing projects from
|
theta-suite is a **composition** repo: it builds four applications from their
|
||||||
their git submodules and adds the glue that wires them together. It does not
|
git submodules and adds the glue that wires them together — plus a shared
|
||||||
fork or patch them — both projects work unchanged on their own.
|
[OpenBao](https://openbao.org/) secrets store — on one Docker network. It
|
||||||
|
does not fork or patch the components; it composes and configures them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The three repos
|
## Components
|
||||||
|
|
||||||
| Repo | Role |
|
| Repo / image | 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/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/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. |
|
| [`theta42/jump-host`](https://github.com/theta42/jump-host) | Directory-driven SSH jump host (sshd + Node web UI). Image (`Dockerfile`). |
|
||||||
|
| [`theta42/ldap-client`](https://github.com/theta42/ldap-client) | Enrolls real Linux hosts into the directory (SSSD + AuthorizedKeysCommand). Also the opt-in `ldap-test-host` fixture. |
|
||||||
|
| `quay.io/openbao/openbao` | Central secrets store (Vault fork), KV-v2 at `secret/`. |
|
||||||
|
| `theta42/theta-suite` (this repo) | Composes all of the above on one network + automates first-run wiring. |
|
||||||
|
|
||||||
The two projects are pinned as **git submodules**. `git clone --recursive`
|
The four applications are pinned as **git submodules**; OpenBao uses the
|
||||||
fetches all three in one step; `git submodule update --remote` bumps them.
|
upstream image. `git clone --recursive` fetches the submodules in one step;
|
||||||
|
`git submodule update --remote` bumps them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The two containers
|
## The stack
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────┐
|
||||||
│ your browser / apps / direct LDAP clients │
|
│ browser / OIDC apps │ SSH clients │ Linux hosts │
|
||||||
└───────────────┬──────────────────────────────┘
|
│ │ │ (PAM/SSSD, sudo, keys) │
|
||||||
│ https (:443) ldaps (:636)
|
└────────┬────────────┴──────┬──────┴───────────┬───────────┘
|
||||||
┌─────────▼─────────┐
|
https (:443) ssh (:2222) ldaps (:636)
|
||||||
│ proxy container │ OpenResty :80/:443/:4443
|
│ │ │
|
||||||
│ (OIDC + LDAP) │ Node mgmt app :3000 (localhost only)
|
┌────────▼────────┐ ┌──────────▼────────┐ │
|
||||||
│ │ bundled Redis (127.0.0.1:6379)
|
│ proxy │ │ jump-host │ │
|
||||||
└─────────┬─────────┘
|
│ OpenResty │ │ sshd :2222 │ │
|
||||||
┌─────────────┼────────────────────────────┐
|
│ :80/:443/:4443 │ │ web UI :3002 │ │
|
||||||
│ ldaps:636 │ http:3001 (internal) │ OIDC token + userinfo
|
│ mgmt app :3000 │ └────────┬──────────┘ │
|
||||||
│ (docker net)│ (docker net, not published)│ (server-to-server)
|
└────────┬─────────┘ │ OIDC + LDAP │
|
||||||
▼ ▼ │
|
│ http:3001 (internal)│ via sso-manager │
|
||||||
┌──────────────────────────────┐ │
|
▼ ▼ ▼
|
||||||
│ sso-manager container │◄──────────────────┘
|
┌───────────────────────────────────────────────────────┐
|
||||||
│ OIDC provider (Express) │ bundled Redis (127.0.0.1:6379)
|
│ sso-manager (Express + OpenLDAP + Redis) │
|
||||||
│ OpenLDAP (slapd) │ web UI :3001 (localhost only)
|
│ OIDC provider + LDAP directory │
|
||||||
│ ldaps :636 (published) │
|
│ web UI :3001 (internal) ldaps :636 (published) │
|
||||||
└───────────────────────────────┘
|
└───────────────────────────────────────────────────────┘
|
||||||
▲
|
▲ loads secrets at boot (scoped token each)
|
||||||
│ ldaps :636 (published to host) — legacy apps bind directly
|
┌───────────┴───────────────────┐
|
||||||
│
|
│ openbao (KV-v2 at secret/) │ ← central secrets store
|
||||||
┌──────────────────────────────┐
|
│ :8200 (internal) │ per-user + per-app KV
|
||||||
│ legacy apps (Gitea, Emby, …)│
|
│ :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`
|
All four services bundle their **own Redis** (sso-manager, proxy, jump-host
|
||||||
in three places that ignore config; the SSO's models default to the same). Two
|
each run a 127.0.0.1:6379 instance) and share the `openbao` secrets store.
|
||||||
redis instances is the no-source-patch path and is fine at this scale.
|
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
|
### What's exposed, what's not
|
||||||
|
|
||||||
| Port | On host? | Purpose |
|
| Port | Service | On host? | Purpose |
|
||||||
|------|----------|---------|
|
|------|---------|----------|---------|
|
||||||
| `443` (proxy) | **yes** | the public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
|
| `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) |
|
| `80` | proxy | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
|
||||||
| `4443` (proxy) | yes (optional) | alt HTTPS listener |
|
| `4443` | proxy | yes (optional) | alt HTTPS listener |
|
||||||
| `3000` (proxy) | localhost only | proxy mgmt UI/API (first-run convenience; fronted by 443 normally) |
|
| `3000` | proxy | localhost/LAN | proxy mgmt UI/API (fronted by 443 normally) |
|
||||||
| `636` (sso) | yes | LDAPS for legacy direct-LDAP clients |
|
| `3001` | sso-manager | localhost/LAN | SSO web UI (fronted by the proxy normally) |
|
||||||
| `3001` (sso) | localhost only | SSO web UI (first-run convenience; fronted by the proxy normally) |
|
| `636` | sso-manager | **yes** | LDAPS for direct-LDAP clients (Linux hosts, LDAP-native apps) |
|
||||||
| `389` (sso) | **no** | plain LDAP — internal only (app↔slapd over localhost) |
|
| `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 +136,14 @@ actual work, running **inside the sso-manager container** (bind-mounted
|
|||||||
read-only from this repo). It's deliberately self-contained — only Node
|
read-only from this repo). It's deliberately self-contained — only Node
|
||||||
built-ins (`child_process`, `crypto`, `fs`) + global `fetch`, and it reads its
|
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`
|
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`.
|
1. **Build + start sso-manager**, wait for `/health`.
|
||||||
2. **LDAP service account** — `ldapadd` `cn=ldapclient,ou=people,<base>` (an
|
2. **LDAP service account** — `ldapadd` `cn=ldapclient,ou=people,<base>` (an
|
||||||
@@ -97,14 +158,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
|
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
|
by `app_sso_oauth_admin`, satisfied by step 3). The SSO **generates** the
|
||||||
`client_id`/`client_secret` (UUIDs) — supplied creds are ignored — so the
|
`client_id`/`client_secret` (UUIDs) — supplied creds are ignored — so the
|
||||||
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`**
|
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`
|
||||||
(the sso-manager mounts `./config` read-write for this; the proxy mounts it
|
and into OpenBao at `secret/proxy/conf`** (the sso-manager mounts `./config`
|
||||||
read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret`
|
read-write for this; the proxy mounts it read-only). If `proxy-secrets.js`
|
||||||
matching an existing client, they are kept; if the client exists but the file
|
already holds a `clientId`+`clientSecret` matching an existing client, they
|
||||||
has no usable secret, the secret is rotated and written back.
|
are kept; if the client exists but the file has no usable secret, the
|
||||||
6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint points
|
secret is rotated and written back.
|
||||||
`CONF_SECRETS` at `./config/proxy-secrets.js`, so `@simpleworkjs/conf`
|
6. **Build + start the proxy + jump-host**, wait for `/health`. Each
|
||||||
(≥1.2.0) reads the OAuth creds + LDAP bind creds from the file.
|
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** —
|
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
|
`setup.sh` runs a short script inside the proxy container that calls its
|
||||||
Host model directly (`Host.create({host, ip, targetPort, ...})`), rather
|
Host model directly (`Host.create({host, ip, targetPort, ...})`), rather
|
||||||
@@ -120,20 +183,25 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
|
|||||||
|
|
||||||
`setup.sh` then prints the first-admin login + the public URLs.
|
`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
|
Config and secrets live in two layers: an operator-edited
|
||||||
entrypoint points the `CONF_SECRETS` env var (`@simpleworkjs/conf` >= 1.2.0)
|
`./config/*-secrets.js` file (gitignored, bind-mounted) and the OpenBao
|
||||||
at its file early, before the app starts:
|
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/sso-secrets.js (sso-manager, ./config RW)
|
||||||
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO)
|
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_*
|
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → secrets file → app_*
|
||||||
env`, where **env beats the secrets file**. So compose passes **no `app_*` env
|
env`, where **env beats the secrets file**. Then `@simpleworkjs/bao-conf`
|
||||||
vars** (only `NODE_ENV`, `NODE_PORT`) — that makes the secrets file
|
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
|
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
|
(LDAP base DN, admin password, JWT secret, cert CN) from `sso-secrets.js` via
|
||||||
an in-container `node` call.
|
an in-container `node` call.
|
||||||
@@ -151,12 +219,15 @@ end-to-end.
|
|||||||
|
|
||||||
## Idempotency
|
## 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/`**.
|
- The LDAP service account + admin passwords are **reset to `./config/`**.
|
||||||
- Group membership is ensured (add is a no-op if already a member).
|
- 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;
|
- 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
|
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
|
||||||
compose down`, or after restoring from backup.
|
compose down`, or after restoring from backup.
|
||||||
@@ -165,17 +236,39 @@ compose down`, or after restoring from backup.
|
|||||||
|
|
||||||
## Backups and restore
|
## 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`,
|
`./backups/<timestamp>/` before each rebuild (keeps the last `BACKUP_KEEP`,
|
||||||
default 5). State lives on named volumes (`ldap-data`, `sso-data`, `proxy-data`)
|
default 5). State lives on named volumes (`ldap-data`, `ldap-certs`,
|
||||||
and survives recreation; `down -v` wipes them. Redis is persisted with AOF +
|
`sso-data`, `proxy-data`, `proxy-cache`, `proxy-logs`, `jump-data`,
|
||||||
RDB on those volumes. For the full manual-backup + restore runbook (full /
|
`jump-redis-data`, `openbao-data`) and survives recreation; `down -v` wipes
|
||||||
Redis-only / LDAP-only, with the AOF-vs-RDB note), see the *Backups and
|
them. Redis is persisted with AOF + RDB on those volumes. For the full
|
||||||
restore* section of the [README](https://github.com/theta42/theta-env#backups-and-restore).
|
manual-backup + restore runbook (full / Redis-only / LDAP-only, with the
|
||||||
Quick LDAP backup:
|
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
|
```bash
|
||||||
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif
|
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)
|
[← Back to Home](index.html)
|
||||||
@@ -4,20 +4,24 @@ 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.
|
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
|
The whole theta42 identity, access, and secrets stack in one repo, brought up
|
||||||
single command — for home labs and small businesses.
|
with a single command — for home labs and small businesses.
|
||||||
|
|
||||||
It wires together two projects that already work on their own —
|
It composes four applications around a shared secrets store:
|
||||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
|
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
|
||||||
LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an
|
LDAP directory), [Proxy](https://theta42.github.io/proxy/) (an OIDC-protected
|
||||||
OIDC-protected reverse proxy that can also look users up directly in LDAP) —
|
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
|
[Jump Host](https://theta42.github.io/jump-host/) (directory-driven SSH access
|
||||||
SSO and pointing it at the right LDAP directory, with hostnames and secrets
|
through one public entry point), and
|
||||||
generated from one `setup.env`. A third component, the
|
[ldap-client](https://theta42.github.io/ldap-client/) (enrolls your Linux
|
||||||
[Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH
|
hosts into the directory for PAM/SSSD, sudo, and SSH keys). All of them read
|
||||||
access to your machines through one public entry point.
|
their secrets at boot from [OpenBao](https://openbao.org/), the central secrets
|
||||||
|
store. `setup.sh` automates the fiddly part: registering the proxy as an OIDC
|
||||||
|
client of the SSO, pointing every component at the right LDAP directory and
|
||||||
|
the OpenBao token it needs, and generating hostnames and secrets from one
|
||||||
|
`setup.env`.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -31,9 +35,9 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
|
|||||||
|
|
||||||
## Why this over running them separately
|
## Why this over running them separately
|
||||||
|
|
||||||
Each project works standalone, but they only become useful together once the
|
The components are designed to integrate — they're only useful together once
|
||||||
proxy is registered as an OIDC client of the SSO *and* pointed at the SSO's
|
the proxy is registered as an OIDC client of the SSO *and* pointed at the
|
||||||
LDAP directory — and the domain has to match across half a dozen config
|
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`
|
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
|
asks for your domain once, generates both apps' config with it filled in
|
||||||
everywhere, registers the proxy as an OIDC client automatically, and
|
everywhere, registers the proxy as an OIDC client automatically, and
|
||||||
@@ -46,9 +50,20 @@ snapshots state before every rebuild.
|
|||||||
- **Proxy** — add the hosts you want to protect with OIDC login.
|
- **Proxy** — add the hosts you want to protect with OIDC login.
|
||||||
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
|
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
|
||||||
LDAP-native apps authenticate against the same directory.
|
LDAP-native apps authenticate against the same directory.
|
||||||
|
- **Hierarchical groups & permissions** — every adopted host and app gets its own
|
||||||
|
`admin`/`access`/`capability` groups, generated from the Directory; they double
|
||||||
|
as real POSIX groups for sudo/SSH. See
|
||||||
|
[Group & Permission Model](GROUPS.html).
|
||||||
|
- **ldap-client** — enroll Linux hosts into the directory (PAM/SSSD login,
|
||||||
|
sudo, SSH keys); the host inventory shows up in the SSO UI and drives
|
||||||
|
jump-host routing.
|
||||||
- **SSH Jump Host** — `ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
|
- **SSH Jump Host** — `ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
|
||||||
or an interactive picker; access is driven by directory group membership, with
|
or an interactive picker; access is driven by directory group membership, with
|
||||||
a web UI for audit + metrics.
|
a web UI for audit + metrics.
|
||||||
|
- **Central secrets (OpenBao)** — every component loads its secrets from one
|
||||||
|
[OpenBao](https://openbao.org/) instance at boot; each user gets personal
|
||||||
|
secret storage, and admins mint scoped tokens for external apps. See
|
||||||
|
[Secrets](secrets.html).
|
||||||
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a
|
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a
|
||||||
browser session.
|
browser session.
|
||||||
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
|
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
|
||||||
@@ -57,16 +72,16 @@ snapshots state before every rebuild.
|
|||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recursive https://github.com/theta42/theta-env.git
|
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||||
cd theta-env
|
cd theta-suite
|
||||||
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
|
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
|
||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run
|
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run
|
||||||
any time to converge the stack to `./config/`. For the full config reference,
|
any time to converge the stack to `./config/`. For the full config reference,
|
||||||
architecture, and running each project standalone, see the
|
architecture, see the
|
||||||
**[GitHub repository](https://github.com/theta42/theta-env)**.
|
**[GitHub repository](https://github.com/theta42/theta-suite)**.
|
||||||
|
|
||||||
## Related projects
|
## Related projects
|
||||||
|
|
||||||
@@ -76,3 +91,5 @@ architecture, and running each project standalone, see the
|
|||||||
stack runs in front of it.
|
stack runs in front of it.
|
||||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — the SSH jump
|
- **[Jump Host](https://theta42.github.io/jump-host/)** — the SSH jump
|
||||||
host this stack brings up.
|
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,24 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
This directory is the GitHub Pages documentation site for the Jump Host project.
|
||||||
|
|
||||||
|
**Live site:** https://theta42.github.io/jump-host/
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- `index.md` — overview and quick start
|
||||||
|
- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP
|
||||||
|
- `architecture.md` — how auth, access resolution, key injection, and bridging work
|
||||||
|
- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL
|
||||||
|
|
||||||
|
## Local preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gem install jekyll bundler
|
||||||
|
cd docs && jekyll serve
|
||||||
|
# http://localhost:4000/jump-host/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically.
|
||||||
@@ -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 the SSO's
|
||||||
|
> [Directory & Inventory](https://theta42.github.io/sso-manager-node/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 the SSO Manager and Proxy. Login is OIDC against the SSO plus a
|
||||||
|
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
|
||||||
|
`auth.adminGroups`. It exposes:
|
||||||
|
|
||||||
|
- `GET /health` — open; `{status, activeSessions, version}`
|
||||||
|
- `GET /api/sessions` — active sessions
|
||||||
|
- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
|
||||||
|
- `GET /api/metrics` — counters (total, failures, top users/hosts)
|
||||||
|
|
||||||
|
Audit events and counters live in redis. Each event captures: user, auth method,
|
||||||
|
mode (grammar/picker), target slug/address/port, channel type, client IP,
|
||||||
|
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
|
||||||
|
|
||||||
|
## Where it sits in the stack
|
||||||
|
|
||||||
|
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
|
||||||
|
OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
|
||||||
|
jump host reads.
|
||||||
|
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
|
||||||
|
downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
|
||||||
|
connects into.
|
||||||
|
- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
|
||||||
|
under TLS.
|
||||||
|
- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
|
||||||
@@ -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: 90 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Home
|
||||||
|
description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jump Host
|
||||||
|
|
||||||
|
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
||||||
|
stack. Users SSH into **one** public host and land on any downstream host
|
||||||
|
they're entitled to — authenticated against the shared LDAP directory,
|
||||||
|
authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
|
||||||
|
inventory graph, and audited end to end.
|
||||||
|
|
||||||
|
No per-host accounts, no distributing keys, no VPN. The same people who log in
|
||||||
|
to your SSO are the people who can reach your machines — and only the machines
|
||||||
|
their directory groups grant.
|
||||||
|
|
||||||
|
Part of the theta42 self-hosted identity stack, alongside
|
||||||
|
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
|
||||||
|
[Proxy](https://theta42.github.io/proxy/), composable with one command via
|
||||||
|
[theta-env](https://theta42.github.io/theta-env/).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<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.
|
||||||
|
|
||||||
|
This jump host answers both from your directory:
|
||||||
|
|
||||||
|
- **Authorization is your directory graph.** The hosts you can reach are the
|
||||||
|
union of your LDAP groups × the SSO's inventory (the `host_<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.** The jump host holds one key and injects its
|
||||||
|
public half into your `sshPublicKey` on first use, then connects downstream
|
||||||
|
**as you**. Downstream hosts already serve keys from LDAP (via
|
||||||
|
[ldap-client](https://github.com/theta42/ldap-client)'s
|
||||||
|
`AuthorizedKeysCommand`), so nothing downstream needs configuring.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
|
||||||
|
SFTP included (WinSCP works)
|
||||||
|
- **Interactive TUI host picker** on plain login, scoped to your access
|
||||||
|
- **LDAP inbound auth** — public key or password (keys-only policy recommended
|
||||||
|
for a public host)
|
||||||
|
- **Directory-driven access** — reachable hosts come from the SSO inventory, not
|
||||||
|
a static list
|
||||||
|
- **Per-user key injection** — no downstream changes, no key distribution
|
||||||
|
- **Shell, exec, and SFTP** bridging
|
||||||
|
- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
|
||||||
|
audit log, per-user/per-host counters
|
||||||
|
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
||||||
|
downstream host-key fingerprint
|
||||||
|
|
||||||
|
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
|
||||||
|
installer, or bundled in theta-env
|
||||||
|
|
||||||
|
## Get it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/theta42/jump-host.git
|
||||||
|
cd jump-host
|
||||||
|
cp secrets.js.example config/jump-secrets.js # then edit it
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
For bare-metal and the bundled theta-env
|
||||||
|
option, see **[Installation](installation.html)**.
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
|
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
|
||||||
|
directory + OIDC provider + the inventory graph this jump host reads.
|
||||||
|
- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
|
||||||
|
same identity; fronts this jump host's web UI.
|
||||||
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
|
||||||
|
jump host included, with one command.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Documentation
|
||||||
|
|
||||||
|
This directory contains the GitHub Pages documentation site for the Proxy project.
|
||||||
|
|
||||||
|
**Live site:** https://theta42.github.io/proxy/
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- `index.md` - Home page with project overview
|
||||||
|
- `installation.md` - Installation and setup guide
|
||||||
|
- `api.md` - Complete API reference
|
||||||
|
- `architecture.md` - System architecture and design
|
||||||
|
- `contributing.md` - Development and contribution guide
|
||||||
|
|
||||||
|
## Local Preview
|
||||||
|
|
||||||
|
To preview the site locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Jekyll (one-time setup)
|
||||||
|
gem install jekyll bundler
|
||||||
|
|
||||||
|
# Run local server
|
||||||
|
cd docs
|
||||||
|
jekyll serve
|
||||||
|
|
||||||
|
# View at http://localhost:4000/proxy/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`.
|
||||||
|
|
||||||
|
## Updating Documentation
|
||||||
|
|
||||||
|
1. Edit markdown files in this directory
|
||||||
|
2. Commit and push to master branch
|
||||||
|
3. GitHub Pages automatically rebuilds (may take 1-2 minutes)
|
||||||
|
4. Changes visible at https://theta42.github.io/proxy/
|
||||||
@@ -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 an SSO Manager (or
|
||||||
|
another OIDC provider) — the same login your other connected apps use.
|
||||||
|
|
||||||
|
Either way, once logged in, what they're actually *allowed to do* here is
|
||||||
|
controlled by permissions, described below.
|
||||||
|
|
||||||
|
## Groups
|
||||||
|
|
||||||
|
A **group** here is just a named list of local usernames, used to grant
|
||||||
|
the same permission to several people at once instead of one at a time.
|
||||||
|
If you're using SSO instead of local accounts, group membership normally
|
||||||
|
comes from your identity provider instead — local groups exist mainly for
|
||||||
|
the local-account case.
|
||||||
|
|
||||||
|
## Permissions: scope + role
|
||||||
|
|
||||||
|
Each **permission** entry grants one subject (a user or a group) one
|
||||||
|
**role**, at one **scope** — the two are independent choices:
|
||||||
|
|
||||||
|
**Scope** — *where* the role applies:
|
||||||
|
|
||||||
|
- **Domain** — only hosts under one specific domain (e.g. someone can
|
||||||
|
manage everything under `example.com`, but can't see or touch a
|
||||||
|
completely different domain you also proxy).
|
||||||
|
- **Global** — everywhere, across every domain this proxy manages.
|
||||||
|
|
||||||
|
**Role** — *what* they can do within that scope:
|
||||||
|
|
||||||
|
- **Viewer** — read-only. Can see hosts and their settings, but not
|
||||||
|
change anything.
|
||||||
|
- **Manager** — full control over hosts (create, edit, delete) within
|
||||||
|
that scope.
|
||||||
|
- **Admin** — same host control as Manager, **plus**, but *only when
|
||||||
|
granted at Global scope*, the ability to manage other people's
|
||||||
|
permissions, DNS providers, and local user accounts. An Admin role
|
||||||
|
granted at Domain scope instead of Global behaves exactly like Manager
|
||||||
|
for that one domain — it does not unlock those extra admin-only pages.
|
||||||
|
|
||||||
|
In practice: give someone **Manager** on just the domain(s) they're
|
||||||
|
responsible for to delegate day-to-day host management without handing
|
||||||
|
them the keys to everything. Reserve **Global Admin** for people who
|
||||||
|
should be able to change anything, anywhere, including who else has
|
||||||
|
access.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page doesn't cover the exact permission-checking implementation or
|
||||||
|
how SSO group membership maps into this system internally — for that, see
|
||||||
|
[Architecture](architecture.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -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: 328 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 428 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Home
|
||||||
|
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Proxy
|
||||||
|
|
||||||
|
A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a
|
||||||
|
management API and web GUI. It puts any of your apps behind single sign-on
|
||||||
|
(OIDC) and can also look users up directly in LDAP — so the same people who
|
||||||
|
log in to your SSO are the people allowed to reach your proxied apps.
|
||||||
|
|
||||||
|
Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname,
|
||||||
|
and per-host access control tied to your identity provider — managed from a
|
||||||
|
web UI or a REST API, with no downtime on config changes.
|
||||||
|
|
||||||
|
Part of the theta42 self-hosted identity stack, alongside
|
||||||
|
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
|
||||||
|
[theta-env](https://theta42.github.io/theta-env/) (the two composed with one
|
||||||
|
command).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
*(click any screenshot to view full size)*
|
||||||
|
|
||||||
|
## Why this over the alternatives
|
||||||
|
|
||||||
|
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
||||||
|
auto-HTTPS. This one is built around identity: it is both an **OIDC client**
|
||||||
|
of an SSO provider (for browser login) **and** a direct **LDAP client** (for
|
||||||
|
user lookups and per-host access control), so access decisions come from your
|
||||||
|
real user directory, not a static allow-list or a separate auth proxy bolted
|
||||||
|
on top. The trade-off is that it expects an OIDC/LDAP identity source to point
|
||||||
|
at — it is not an auth server on its own. Pair it with
|
||||||
|
[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP +
|
||||||
|
OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider +
|
||||||
|
LDAP directory you already run.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
||||||
|
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
||||||
|
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
||||||
|
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||||
|
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
||||||
|
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
||||||
|
it's never ambiguous which one gated a request)
|
||||||
|
- **Role-based access control** — global admins, local groups, and
|
||||||
|
per-domain permissions (viewer/manager)
|
||||||
|
- Self-service API tokens for scripting/CI without a browser session
|
||||||
|
- Web UI and a full REST API
|
||||||
|
|
||||||
|
## Get it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/theta42/proxy.git
|
||||||
|
cd proxy && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
For the full set of install options (Docker, bare-metal, or as part of the combined
|
||||||
|
SSO + proxy stack), configuration reference, and API docs, see the
|
||||||
|
**[GitHub repository](https://github.com/theta42/proxy)**.
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
|
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
|
||||||
|
provider + LDAP directory this proxy is designed to sit in front of.
|
||||||
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and
|
||||||
|
SSO Manager together with one command.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Quickstart
|
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
|
# Quickstart Guide
|
||||||
@@ -12,8 +12,7 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
|
- 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).
|
||||||
compose` or the v1 standalone `docker-compose` both work).
|
|
||||||
- Two hostnames that resolve to the host: one for the SSO UI (your `stack.ssoHost`),
|
- 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
|
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`.
|
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
|
## 1. Clone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recursive https://github.com/theta42/theta-env.git
|
git clone --recursive https://github.com/theta42/theta-suite.git
|
||||||
cd theta-env
|
cd theta-suite
|
||||||
```
|
```
|
||||||
|
|
||||||
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
|
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
|
||||||
@@ -141,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`
|
passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js`
|
||||||
already holds its creds).
|
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
|
```bash
|
||||||
ldapsearch -x -H ldaps://<host>:636 \
|
ldapsearch -x -H ldaps://<host>:636 \
|
||||||
@@ -164,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
|
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
|
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
|
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:
|
backup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -182,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
|
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
|
`Authorization: Bearer sso_…` (SSO) or `prx_…` (proxy). A token authenticates as
|
||||||
its creator with their permissions. See each submodule's DEPLOYMENT.
|
its creator with their permissions. See each submodule's DEPLOYMENT.
|
||||||
- See [Architecture](architecture.html) for how it all fits together, and
|
- See [Architecture](architecture.html) for how it all fits together.
|
||||||
[Standalone](standalone.html) to run either project on its own.
|
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
|
Sitemap: https://theta42.github.io/theta-suite/sitemap.xml
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Secrets (OpenBao)
|
title: Secrets (OpenBao)
|
||||||
description: theta-env's central secrets architecture — OpenBao as the single store for all app, per-user, and external-app secrets, with scoped tokens and policies.
|
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
|
# Secrets — OpenBao as the central store
|
||||||
|
|
||||||
theta-env keeps **every secret in one place: [OpenBao](https://openbao.org/)**
|
theta-suite keeps **every secret in one place: [OpenBao](https://openbao.org/)**
|
||||||
(a Vault-community fork), running on the `theta-net` docker network at
|
(a Vault-community fork), running on the `theta-net` docker network at
|
||||||
`http://openbao:8200`. The three apps (SSO Manager, proxy, jump host) load
|
`http://openbao:8200`. The three apps (SSO Manager, proxy, jump host) load
|
||||||
their boot secrets from it; end users get personal per-user secret storage
|
their boot secrets from it; end users get personal per-user secret storage
|
||||||
@@ -60,23 +60,49 @@ never passed to a service container.
|
|||||||
|
|
||||||
| Policy | Capabilities | Held by |
|
| Policy | Capabilities | Held by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`; `update` on `auth/token/create/sso-broker`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
|
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; `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) |
|
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
|
||||||
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
|
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
|
||||||
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_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) |
|
| `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) |
|
| `app-<name>` | read/write `secret/apps/<name>/*` | per-external-app tokens (minted by an admin) |
|
||||||
|
|
||||||
**Token role `sso-broker`** — `allowed_policies=sso-admin`,
|
**Token roles** — three, all orphan + renewable:
|
||||||
`allowed_policies_glob=user-*,app-*`, orphan, renewable, `token_period=24h`.
|
|
||||||
The SSO mints per-user, per-admin, and per-app tokens *through* this role at
|
|
||||||
runtime, so it never needs the root token to issue scoped access.
|
|
||||||
|
|
||||||
The three per-app tokens (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
|
- `sso-broker` — `allowed_policies=sso-admin`, `allowed_policies_glob=user-*,app-*`,
|
||||||
`JUMP_VAULT_TOKEN`) are minted orphan + renewable and stored in `./.env` by
|
`token_period=24h`. The SSO mints per-user and per-admin tokens *through*
|
||||||
`setup.sh`. They use OpenBao's default service-token TTL; if one expires,
|
this role at runtime, so it never needs the root token to issue scoped
|
||||||
re-run `./setup.sh` and the `ensure_token` helper re-mints it (the old one
|
access. The 24h period is fine here because the broker re-mints these from
|
||||||
expires on its own). Automated renewal is a planned follow-up, not yet built.
|
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
|
## Seeding
|
||||||
|
|
||||||
@@ -150,6 +176,31 @@ const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-s
|
|||||||
await baoConf.set('apps/my-service/conf', { db_password: '...' });
|
await baoConf.set('apps/my-service/conf', { db_password: '...' });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
## Operator rotation
|
||||||
|
|
||||||
If a secret is exposed (or just on a routine schedule), rotate it at the
|
If a secret is exposed (or just on a routine schedule), rotate it at the
|
||||||
@@ -192,4 +243,5 @@ re-mint the per-app tokens.
|
|||||||
git-destructive operation you can opt into.
|
git-destructive operation you can opt into.
|
||||||
- **Per-app secrets beyond boot config** (e.g. the proxy's DNS-provider creds,
|
- **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
|
the jump host's per-user LDAP SSH keys) moving into OpenBao — only the
|
||||||
boot-critical `*-secrets.js` contents moved in this phase.
|
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
|
||||||
|
|
||||||
|
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||||
|
|
||||||
|
## Writing a Custom Agent
|
||||||
|
|
||||||
|
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
||||||
|
|
||||||
|
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
||||||
|
|
||||||
|
### Agent Skeleton
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// nodejs/agents/discovery/my_custom_agent.js
|
||||||
|
module.exports = {
|
||||||
|
discover: async (config) => {
|
||||||
|
const { url, apiKey } = config; // Provided by your configuration
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 1. Fetch your data from an API
|
||||||
|
// const data = await fetch(...);
|
||||||
|
|
||||||
|
// 2. Map data to Resources
|
||||||
|
resources.push({
|
||||||
|
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
|
||||||
|
name: 'My Switch',
|
||||||
|
slug: 'my-switch-01',
|
||||||
|
metadata: {
|
||||||
|
make: 'Vendor',
|
||||||
|
model: 'Model X',
|
||||||
|
interfaces: [
|
||||||
|
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Map relations to Edges (optional)
|
||||||
|
edges.push({
|
||||||
|
parentSlug: 'my-switch-01',
|
||||||
|
childSlug: 'some-connected-client-slug',
|
||||||
|
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// ... existing config ...
|
||||||
|
discovery: {
|
||||||
|
agents: {
|
||||||
|
my_custom_agent: {
|
||||||
|
enabled: true,
|
||||||
|
cron: '*/30 * * * *', // Run every 30 minutes
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: 'secret-key'
|
||||||
|
},
|
||||||
|
nmap: {
|
||||||
|
enabled: true,
|
||||||
|
cron: '0 * * * *',
|
||||||
|
targetRange: '192.168.1.0/24'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Reconciliation Engine
|
||||||
|
|
||||||
|
When your agent returns its graph, the Reconciliation Engine takes over:
|
||||||
|
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
|
||||||
|
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
|
||||||
|
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
||||||
|
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
|
||||||
@@ -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 SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accounts, Groups & Managers
|
||||||
|
|
||||||
|
This page explains the concepts behind the Users and Groups pages in plain
|
||||||
|
language. If you want the technical schema/attribute-level detail instead,
|
||||||
|
see the [LDAP reference](ldap.html).
|
||||||
|
|
||||||
|
## What's an account?
|
||||||
|
|
||||||
|
Every person (or app) that can sign in through this SSO Manager has an
|
||||||
|
**account** — a username, a display name, maybe an email address, and a
|
||||||
|
password (or, for service accounts, no password at all — see below).
|
||||||
|
Accounts live in the directory this app manages, and any other app you've
|
||||||
|
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
|
||||||
|
same accounts instead of keeping its own separate list of users and
|
||||||
|
passwords.
|
||||||
|
|
||||||
|
## Two kinds of account: people and service accounts
|
||||||
|
|
||||||
|
Most accounts belong to an actual person — check **Users → People** to see
|
||||||
|
them. But sometimes you need an account for something that *isn't* a
|
||||||
|
person: a media server, a backup script, a bind account another app uses to
|
||||||
|
look people up. These are **service accounts**, listed separately under
|
||||||
|
**Users → Service Accounts**, and they're different from a person's account
|
||||||
|
in two ways that matter:
|
||||||
|
|
||||||
|
- **No email required.** A service account doesn't need a mailbox, so the
|
||||||
|
form doesn't ask for one.
|
||||||
|
- **A password is optional.** If you leave it blank, nobody can log in as
|
||||||
|
that account — which is exactly what you want for something that only
|
||||||
|
ever gets used programmatically (a script authenticating with an API
|
||||||
|
token, or another app binding with a fixed, separately-configured
|
||||||
|
password you set yourself). Only give it a password if the account
|
||||||
|
genuinely needs to log in or bind somewhere as itself.
|
||||||
|
|
||||||
|
Aside from those two differences, a service account is a completely normal
|
||||||
|
account under the hood — it can belong to groups, have a manager, and so
|
||||||
|
on, just like anyone else's.
|
||||||
|
|
||||||
|
## Groups: who can do what
|
||||||
|
|
||||||
|
A **group** is just a named list of accounts, used to control access. This
|
||||||
|
app has a handful of built-in groups that grant admin powers (e.g. only
|
||||||
|
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
|
||||||
|
pages at all), but you can also make your own groups for any app you
|
||||||
|
connect — say, a group listing everyone who should be allowed into your
|
||||||
|
photo server. Once a group exists, add or remove members from the
|
||||||
|
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||||
|
that group's name.
|
||||||
|
|
||||||
|
### Groups inside groups
|
||||||
|
|
||||||
|
A group can contain another group, not just people — the *Nested* tab on any
|
||||||
|
group card. Everyone in the inner group counts as a member of the outer one,
|
||||||
|
however many levels deep it goes.
|
||||||
|
|
||||||
|
This is mostly a way to stop repeating yourself. Make one `developers` group,
|
||||||
|
nest it into the handful of things developers should reach, and adding a new
|
||||||
|
developer to that one group grants all of them at once — instead of adding them
|
||||||
|
to each individually and slowly drifting out of sync. The app already does this
|
||||||
|
for itself: super admins are nested into every resource's admin group, and each
|
||||||
|
admin group into its access group, so "can administer it" always implies "can
|
||||||
|
use it".
|
||||||
|
|
||||||
|
Two things it won't let you do: put a group inside itself (directly or round a
|
||||||
|
longer loop), and empty a group completely — every group must keep at least one
|
||||||
|
member.
|
||||||
|
|
||||||
|
A note if you also manage the directory by hand: a group's member list shows
|
||||||
|
what is *directly* listed on it. Someone who gets in through a nested group is
|
||||||
|
a real member but won't appear there — the **Nested** tab shows what is nested,
|
||||||
|
and the API's `effective` view lists everyone who actually gets in.
|
||||||
|
|
||||||
|
## Every account's personal group
|
||||||
|
|
||||||
|
Separately from the groups above, every single account — person or
|
||||||
|
service account — automatically gets its own small, personal group when
|
||||||
|
it's created, named after the account itself. Most of the time you'll
|
||||||
|
never think about this; it exists so that, on a Linux system connected to
|
||||||
|
this directory, each account "owns" its own files by default the same way
|
||||||
|
a normal Unix user account would.
|
||||||
|
|
||||||
|
Occasionally you'll want to share that ownership with someone else — for
|
||||||
|
example, letting a second account also have write access to files a
|
||||||
|
service account owns. That's what the **"Members of `<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 SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Tokens
|
||||||
|
|
||||||
|
This page explains what an API token is and when you'd want one. For the
|
||||||
|
full list of API endpoints a token can call, see the
|
||||||
|
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
|
## What's an API token, in plain terms?
|
||||||
|
|
||||||
|
Normally, you interact with this app by logging in through a web browser.
|
||||||
|
An **API token** (also called a personal access token, or PAT) is an
|
||||||
|
alternative way in — a long, random string that a script, a scheduled job,
|
||||||
|
or another program can use instead of a username and password, to act on
|
||||||
|
your behalf without a human typing a login in each time.
|
||||||
|
|
||||||
|
If you've ever set up a script to talk to GitHub, GitLab, or a similar
|
||||||
|
service using a "token" instead of your real password, this is the same
|
||||||
|
idea.
|
||||||
|
|
||||||
|
## When would you actually need one?
|
||||||
|
|
||||||
|
Most people never need to create one of these — you'll only want a token
|
||||||
|
if you're automating something, for example:
|
||||||
|
|
||||||
|
- A script that syncs users or groups from somewhere else into this SSO
|
||||||
|
Manager on a schedule.
|
||||||
|
- A backup or monitoring job that checks this app's health via its API.
|
||||||
|
- A CI/CD pipeline that needs to register or update an OAuth client
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
If you're not doing any of that, you don't need an API token — just log in
|
||||||
|
normally through the web UI.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Create a token from your Profile page, give it a name so you remember what
|
||||||
|
it's for later, and optionally an expiry. You'll be shown the token's
|
||||||
|
value **exactly once** — copy it somewhere safe immediately, because it
|
||||||
|
can't be viewed again afterward (only revoked or rotated). Whatever script
|
||||||
|
or tool you're using it with sends it along with each request, the same
|
||||||
|
way a browser sends your login session.
|
||||||
|
|
||||||
|
A token acts **as you**, with **your** permissions — if you're not an
|
||||||
|
admin, a token you create can't do admin-only things either. If you ever
|
||||||
|
suspect a token has leaked (ended up somewhere it shouldn't have, like a
|
||||||
|
public script or log file), revoke it immediately from your Profile page;
|
||||||
|
it stops working right away.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page doesn't attempt to list every API endpoint or show request/
|
||||||
|
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Connecting Apps (Single Sign-On)
|
||||||
|
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Connecting Apps (Single Sign-On)
|
||||||
|
|
||||||
|
This page explains, in plain language, what happens when you "connect" an
|
||||||
|
app to your SSO Manager so people can log into it with their existing
|
||||||
|
account. For the technical endpoint/token detail, see the
|
||||||
|
[OAuth reference](oauth.html).
|
||||||
|
|
||||||
|
## What does "single sign-on" actually mean?
|
||||||
|
|
||||||
|
Instead of every app you run having its own separate list of usernames and
|
||||||
|
passwords, they all check with this SSO Manager instead. You log in once,
|
||||||
|
here, and any connected app trusts that login — no separate password to
|
||||||
|
remember or manage for each one. If you ever need to lock someone out
|
||||||
|
everywhere at once, you do it in one place (deactivate their account here)
|
||||||
|
instead of hunting down every app individually.
|
||||||
|
|
||||||
|
The technology behind this is called **OAuth 2.0** and **OpenID Connect
|
||||||
|
(OIDC)** — you'll see both names used, often together, referring to the
|
||||||
|
same thing. You don't need to understand the protocol to use this page;
|
||||||
|
what matters practically is the handful of concepts below.
|
||||||
|
|
||||||
|
## What's a "client"?
|
||||||
|
|
||||||
|
Every app you connect is registered here as a **client** — a single entry
|
||||||
|
in the Directory representing that one app. Registering a client
|
||||||
|
gives you a **Client ID** and **Client Secret**: think of these like a
|
||||||
|
username and password, but for the *app itself* rather than for a person.
|
||||||
|
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||||
|
screen, along with the discovery URL shown at the top of this page, and
|
||||||
|
that app is now able to ask this SSO Manager to authenticate people on its
|
||||||
|
behalf.
|
||||||
|
|
||||||
|
**Treat the Client Secret like a password** — anyone who has it can
|
||||||
|
impersonate that app when talking to your SSO Manager. If you ever suspect
|
||||||
|
it's leaked, rotate it from the client's card.
|
||||||
|
|
||||||
|
## What are "scopes"?
|
||||||
|
|
||||||
|
**Scopes** control what information a connected app is allowed to ask for
|
||||||
|
about the person logging in — their username, email, group memberships,
|
||||||
|
and so on. Most apps tell you exactly which scopes they need in their own
|
||||||
|
setup instructions; when in doubt, the default set (`openid`, `profile`,
|
||||||
|
`email`, `groups`) covers what nearly every app expects.
|
||||||
|
|
||||||
|
## "Restrict to Groups"
|
||||||
|
|
||||||
|
By default, *any* account with an SSO Manager login can sign into a
|
||||||
|
connected app. If that's not what you want — say, a home automation
|
||||||
|
dashboard that only certain family members should reach — set **Restrict
|
||||||
|
to Groups** on that client to one of your [groups](concepts-accounts.html).
|
||||||
|
Only members of that group will be allowed to log into that particular
|
||||||
|
app; everyone else gets turned away at the login step, even though their
|
||||||
|
SSO Manager account still works everywhere else.
|
||||||
|
|
||||||
|
## Redirect URIs
|
||||||
|
|
||||||
|
A **Redirect URI** is the exact web address the connected app wants people
|
||||||
|
sent back to once they've logged in here — it's a security measure so an
|
||||||
|
attacker can't trick the login flow into redirecting somewhere else. The
|
||||||
|
app's own setup instructions will tell you this value; copy it in exactly
|
||||||
|
as given. If the app is reachable via more than one hostname (for example,
|
||||||
|
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
|
||||||
|
this field supports wildcard patterns — see the inline help under the
|
||||||
|
field itself for the exact syntax.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page intentionally skips the protocol-level detail (exact endpoint
|
||||||
|
URLs, token formats, claim names). If you're troubleshooting a connection
|
||||||
|
or building something against the API directly, see the
|
||||||
|
[OAuth reference](oauth.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Configuration
|
||||||
|
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
The app loads configuration via
|
||||||
|
[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which
|
||||||
|
deep-merges, in order (later wins):
|
||||||
|
|
||||||
|
1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`,
|
||||||
|
`localhost`, `SSO Manager`).
|
||||||
|
2. `conf/<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
|
||||||
|
|
||||||
|
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
|
||||||
|
|
||||||
|
There are three primary **Kinds** of resources you can define:
|
||||||
|
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
|
||||||
|
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
|
||||||
|
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
|
||||||
|
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
|
||||||
|
|
||||||
|
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
|
||||||
|
|
||||||
|
## Automatic LDAP Group Creation
|
||||||
|
|
||||||
|
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
|
||||||
|
|
||||||
|
1. `<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 the SSO Manager 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 — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||||
|
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||||
|
|
||||||
|
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||||
|
|
||||||
|
### Linux hosts (ldap-client)
|
||||||
|
|
||||||
|
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<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: 141 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 221 KiB |
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Home
|
||||||
|
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SSO Manager
|
||||||
|
|
||||||
|
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
|
||||||
|
and a web management UI — for home labs and small businesses that want their
|
||||||
|
own identity provider instead of a hosted one.
|
||||||
|
|
||||||
|
One place to manage your users and groups, one login (OIDC) your modern apps
|
||||||
|
can use, and one LDAP directory your older or odder apps can bind to directly.
|
||||||
|
Everything runs on your own hardware; no phone-home, no hosted control plane,
|
||||||
|
no per-user pricing.
|
||||||
|
|
||||||
|
Part of the theta42 self-hosted identity stack, alongside
|
||||||
|
[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy)
|
||||||
|
and [theta-env](https://theta42.github.io/theta-env/) (the two composed with
|
||||||
|
one command).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
*(click any screenshot to view full size)*
|
||||||
|
|
||||||
|
## Why this over the alternatives
|
||||||
|
|
||||||
|
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
||||||
|
LDAP is either a paid feature, a federation target you have to run
|
||||||
|
separately, or absent. If your stack already has apps that speak LDAP
|
||||||
|
directly — or you just want one real directory as the source of truth — you
|
||||||
|
end up running *two* identity systems and keeping them in sync.
|
||||||
|
|
||||||
|
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
||||||
|
apps and LDAP apps read from the same users and groups. The trade-off is
|
||||||
|
scope: it's intentionally small and self-hosted, not an enterprise IAM suite.
|
||||||
|
If you want a lightweight, self-contained identity provider with a real LDAP
|
||||||
|
backend, that's the niche.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
|
||||||
|
tokens; standard discovery document at `/.well-known/openid-configuration`.
|
||||||
|
- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public
|
||||||
|
keys, and sudo roles, with `memberOf` + referential-integrity overlays.
|
||||||
|
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||||
|
invite and password-reset flows over email; self-service profile + API
|
||||||
|
tokens.
|
||||||
|
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||||
|
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||||
|
directory.
|
||||||
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
|
run the pieces separately via `app_*` env config.
|
||||||
|
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||||
|
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||||
|
|
||||||
|
## Get it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/theta42/sso-manager-node.git
|
||||||
|
cd sso-manager-node
|
||||||
|
cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
For the full set of install options
|
||||||
|
(Docker, bare-metal, or as part of the combined SSO + proxy stack), the
|
||||||
|
`app_*` env reference, and the OAuth/LDAP internals, see the
|
||||||
|
**[GitHub repository](https://github.com/theta42/sso-manager-node)**.
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
|
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||||
|
reverse proxy, designed to sit in front of this SSO.
|
||||||
|
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
||||||
|
uses this SSO's directory to decide who may reach which machine.
|
||||||
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||||
|
Manager and the proxy together with one command.
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: LDAP
|
||||||
|
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
|
||||||
|
---
|
||||||
|
|
||||||
|
# LDAP Directory
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
> Looking for a plainer explanation of accounts, groups, and managers
|
||||||
|
> instead of schema/attribute detail? See
|
||||||
|
> [Accounts, Groups & Managers](concepts-accounts.html).
|
||||||
|
|
||||||
|
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||||
|
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||||
|
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||||
|
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||||
|
theta42/proxy, etc.
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
dc=yourdomain,dc=com
|
||||||
|
├── ou=people users (inetOrgPerson + posixAccount + …)
|
||||||
|
├── ou=groups groups (groupOfNames)
|
||||||
|
└── ou=policies password policies (pwdPolicy)
|
||||||
|
└── cn=ppolicy default policy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
User entries are `cn=<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 the SSO Manager (for
|
||||||
|
example, the bundled `theta-env` stack), use the internal service name:
|
||||||
|
|
||||||
|
```
|
||||||
|
ldaps://sso-manager:636
|
||||||
|
```
|
||||||
|
|
||||||
|
In `conf/secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ldap: {
|
||||||
|
ldapsHost: 'sso-manager',
|
||||||
|
ldapsPort: 636,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The proxy in theta-env already uses this internally. The bundled slapd cert
|
||||||
|
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
|
||||||
|
so hostname verification works without extra setup.
|
||||||
|
|
||||||
|
### 2. LAN host behind your router (best for separate home-lan machines)
|
||||||
|
|
||||||
|
Create an internal-only DNS record — e.g. `ldap.internal.example.com` →
|
||||||
|
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
|
||||||
|
or generate a cert whose SAN/CN matches that internal name:
|
||||||
|
|
||||||
|
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
|
||||||
|
public domain and can complete DNS-01 challenge; the record itself can stay
|
||||||
|
private/routable only inside your LAN.
|
||||||
|
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
|
||||||
|
`ldap.internal.example.com`, and distribute the CA cert to clients.
|
||||||
|
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
|
||||||
|
the generated `ldap.crt` to each client and trust it.
|
||||||
|
|
||||||
|
In `conf/secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ldap: {
|
||||||
|
ldapsHost: 'ldap.internal.example.com',
|
||||||
|
ldapsPort: 636,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
|
||||||
|
|
||||||
|
### 3. Public hostname (acceptable only behind a VPN/firewall)
|
||||||
|
|
||||||
|
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
|
||||||
|
etc.) or a tightly locked-down firewall rule. In that case the public hostname
|
||||||
|
may be appropriate, but the LDAPS port should still not be reachable from the
|
||||||
|
open internet.
|
||||||
|
|
||||||
|
### Why not just use the LDAP server's IP address?
|
||||||
|
|
||||||
|
TLS clients verify the server name against the certificate. Connecting to
|
||||||
|
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
|
||||||
|
will fail hostname verification unless you disable cert checks — which removes
|
||||||
|
most of the security benefit of LDAPS. Always use a hostname that matches the
|
||||||
|
cert.
|
||||||
|
|
||||||
|
## Service accounts
|
||||||
|
|
||||||
|
A service account is a normal `posixAccount` for something that isn't a
|
||||||
|
person: a media manager, a torrent client, a service like Emby, or a
|
||||||
|
read-only bind account an app uses to look users up — anything that needs a
|
||||||
|
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
|
||||||
|
group for write access (e.g. a `stuff_manager` group granting write rights
|
||||||
|
to a media library). There's only one kind — every account, person or
|
||||||
|
service, is a real `posixAccount` with a UID.
|
||||||
|
|
||||||
|
Create one from the **Users → Service Accounts** tab's "Add new user" form
|
||||||
|
with **This is a service account** checked — it skips the birthday/
|
||||||
|
Terms-of-Service fields a real person's account needs and asks for just an
|
||||||
|
account name. It's flagged (via membership in the `app_sso_service_account`
|
||||||
|
group) so it's listed separately from real people and excluded from "all
|
||||||
|
users" notification broadcasts.
|
||||||
|
|
||||||
|
Email and password are both optional for a service account:
|
||||||
|
|
||||||
|
- No `mail` is set unless you give it one (it never needs a mailbox).
|
||||||
|
- Leaving the password blank is fine — no `userPassword` attribute is set at
|
||||||
|
all, and an entry with no `userPassword` simply can't bind with any
|
||||||
|
password (standard LDAP simple-bind behavior). Only set a password if the
|
||||||
|
account actually needs to authenticate as itself (e.g. a bind-only account
|
||||||
|
an app uses to look users up).
|
||||||
|
|
||||||
|
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
|
||||||
|
against LDAP (independent of this app), and the proxy binds as it — that
|
||||||
|
account won't show up in the Service Accounts tab since it isn't managed
|
||||||
|
through this app, but it keeps working unchanged.
|
||||||
|
|
||||||
|
Either way: don't reuse the admin DN, and give a service account only the
|
||||||
|
group memberships and `manager`s it actually needs.
|
||||||
|
|
||||||
|
Example bind test (a service account with a password set):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -x -H ldaps://sso.example.com:636 \
|
||||||
|
-D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \
|
||||||
|
-b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connecting a 3rd-party app or container
|
||||||
|
|
||||||
|
Most self-hosted apps with an "LDAP authentication" settings page — Gitea,
|
||||||
|
Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via
|
||||||
|
`LDAP_*` env vars, all ask for the same handful of values. These are the
|
||||||
|
`conf.ldap` values from [Configuration](configuration.html), applied to
|
||||||
|
*your* domain:
|
||||||
|
|
||||||
|
| Field the app asks for | Value |
|
||||||
|
|---|---|
|
||||||
|
| Host / URL | `ldaps://<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,111 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: OAuth / OIDC
|
||||||
|
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OAuth 2.0 / OpenID Connect
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
|
||||||
|
> of endpoint-level detail? See
|
||||||
|
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
|
||||||
|
|
||||||
|
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
|
||||||
|
access, refresh, and ID tokens that your apps can consume to authenticate
|
||||||
|
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
||||||
|
can be both your SSO and your user directory at once.
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
The provider publishes a standards-compliant discovery document:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://<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)
|
||||||
|
|
||||||
|
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
|
||||||
|
|
||||||
|
## Why and when to use this?
|
||||||
|
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
|
||||||
|
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
|
||||||
|
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
|
||||||
|
|
||||||
|
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
|
||||||
|
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
|
||||||
|
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
|
||||||
|
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
||||||
|
|
||||||
|
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
||||||
|
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
||||||
|
|
||||||
|
### Example using `theta-env` / Docker Compose
|
||||||
|
|
||||||
|
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=1
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=2
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=3
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
|
## User Locations
|
||||||
|
|
||||||
|
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Secrets Vault
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Secrets Vault
|
||||||
|
|
||||||
|
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
|
||||||
|
|
||||||
|
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
|
||||||
|
|
||||||
|
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
|
||||||
|
|
||||||
|
## Accessing the Vault
|
||||||
|
|
||||||
|
The SSO Manager Vault can be accessed in two ways:
|
||||||
|
|
||||||
|
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
|
||||||
|
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
||||||
|
|
||||||
|
### API Example
|
||||||
|
|
||||||
|
To read secrets from the default key-value store, issue a `GET` request to:
|
||||||
|
`/api/vault/v1/secret/data/sso-manager/conf`
|
||||||
|
|
||||||
|
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
|
||||||
|
|
||||||
|
## Namespaces and Paths
|
||||||
|
|
||||||
|
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
|
||||||
|
|
||||||
|
## Plugin Integration
|
||||||
|
|
||||||
|
Plugin instances store their per-instance secrets in OpenBao at
|
||||||
|
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
|
||||||
|
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
|
||||||
|
in-process, so the SSO Manager reads/writes those secrets server-side through
|
||||||
|
the `sso-broker` token; the admin UI only ever sees masked values, and external
|
||||||
|
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
|
||||||
|
consistently enforced instead of hardcoding them.
|
||||||
@@ -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 +1 @@
|
|||||||
https://github.com/theta42/theta-env/pull/75
|
https://github.com/theta42/theta-suite/pull/75
|
||||||
|
|||||||
@@ -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
|
# 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
|
# ./config/sso-secrets.js + ./config/proxy-secrets.js with your domain filled
|
||||||
@@ -73,13 +73,6 @@ CFG_DOMAIN=example.com
|
|||||||
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
|
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
|
||||||
#CFG_LDAPS_HOST=
|
#CFG_LDAPS_HOST=
|
||||||
|
|
||||||
# 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>
|
|
||||||
|
|
||||||
# ── DO NOT put secrets here ──────────────────────────────────────────────────
|
# ── DO NOT put secrets here ──────────────────────────────────────────────────
|
||||||
# The LDAP admin password, JWT secret, admin password, LDAP service-account
|
# The LDAP admin password, JWT secret, admin password, LDAP service-account
|
||||||
# password, and the proxy's local admin password are all GENERATED (random)
|
# password, and the proxy's local admin password are all GENERATED (random)
|
||||||
@@ -90,6 +83,20 @@ CFG_DOMAIN=example.com
|
|||||||
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
|
# 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) ───────────────────────────
|
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
|
||||||
# If deploying this stack across multiple physical sites to provide local HA
|
# If deploying this stack across multiple physical sites to provide local HA
|
||||||
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
|
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
|
||||||
@@ -100,4 +107,10 @@ CFG_DOMAIN=example.com
|
|||||||
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
|
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
|
||||||
# Example for Site 1:
|
# Example for Site 1:
|
||||||
#LDAP_SERVER_ID=1
|
#LDAP_SERVER_ID=1
|
||||||
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
#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
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
|
# theta-suite setup — one-command bring-up of the unified SSO Manager + Proxy stack.
|
||||||
#
|
#
|
||||||
# git clone --recursive <theta-env> && cd theta-env
|
# git clone --recursive <theta-suite> && cd theta-suite
|
||||||
# cp setup.env.example setup.env # set CFG_DOMAIN to your domain (once)
|
# cp setup.env.example setup.env # set CFG_DOMAIN to your domain (once)
|
||||||
# ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
|
# ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
|
||||||
# ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched)
|
# ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched)
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
# no manual `git pull` needed first.
|
# no manual `git pull` needed first.
|
||||||
#
|
#
|
||||||
# What it does, in order:
|
# What it does, in order:
|
||||||
# 0. Pull theta-env's own latest commit (fast-forward only) and, if it
|
# 0. Pull theta-suite's own latest commit (fast-forward only) and, if it
|
||||||
# moved, re-exec so the rest of this run uses the new script. Never
|
# moved, re-exec so the rest of this run uses the new script. Never
|
||||||
# blocks the run — skips silently with no upstream, warns and continues
|
# blocks the run — skips silently with no upstream, warns and continues
|
||||||
# on any other pull failure (offline, local changes). Skip with
|
# on any other pull failure (offline, local changes). Skip with
|
||||||
@@ -61,6 +61,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
|
||||||
CONFIG_DIR=./config
|
CONFIG_DIR=./config
|
||||||
BACKUP_DIR=./backups
|
BACKUP_DIR=./backups
|
||||||
BACKUP_KEEP="${BACKUP_KEEP:-5}"
|
BACKUP_KEEP="${BACKUP_KEEP:-5}"
|
||||||
@@ -71,6 +72,19 @@ warn() { printf '\033[1;33m[setup]\033[0m %s\n' "$*" >&2; }
|
|||||||
error() { printf '\033[1;31m[setup]\033[0m %s\n' "$*" >&2; }
|
error() { printf '\033[1;31m[setup]\033[0m %s\n' "$*" >&2; }
|
||||||
die() { error "$*"; exit 1; }
|
die() { error "$*"; exit 1; }
|
||||||
|
|
||||||
|
# ── Flags ──────────────────────────────────────────────────────────────────────
|
||||||
|
# --reset-openbao: wipe the OpenBao volume + bao-init.json and re-initialize a
|
||||||
|
# fresh store (no prod data to preserve). Use when OpenBao state is suspect
|
||||||
|
# (stale policies/tokens causing vault 403s). The Redis vault-token cache is
|
||||||
|
# flushed once sso-manager is back up (see the OpenBao bootstrap section).
|
||||||
|
RESET_OPENBAO=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--reset-openbao) RESET_OPENBAO=1 ;;
|
||||||
|
*) warn "unknown argument: $arg (ignored)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# Escape a value for a single-quoted JS string: \ -> \\, ' -> \', then wrap in '...'.
|
# Escape a value for a single-quoted JS string: \ -> \\, ' -> \', then wrap in '...'.
|
||||||
js_str() {
|
js_str() {
|
||||||
local s="$1"
|
local s="$1"
|
||||||
@@ -105,6 +119,13 @@ env_upsert() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Read KEY= from ./.env (empty if absent)
|
||||||
|
env_get() {
|
||||||
|
local key="$1" file=./.env
|
||||||
|
[[ -f "$file" ]] || return 0
|
||||||
|
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- || true
|
||||||
|
}
|
||||||
|
|
||||||
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
|
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
COMPOSE=(docker compose)
|
COMPOSE=(docker compose)
|
||||||
@@ -141,7 +162,7 @@ parse_kv_file() {
|
|||||||
done < "$file"
|
done < "$file"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 0. Self-update: pull theta-env itself, then restart with the new version ──
|
# ── 0. Self-update: pull theta-suite itself, then restart with the new version ──
|
||||||
# Step 1 below only refreshes the proxy/sso-manager-node submodules — it never
|
# Step 1 below only refreshes the proxy/sso-manager-node submodules — it never
|
||||||
# updates setup.sh or this repo's own files. Pull the current branch's
|
# updates setup.sh or this repo's own files. Pull the current branch's
|
||||||
# upstream (fast-forward only) before anything else, and if it moved, re-exec
|
# upstream (fast-forward only) before anything else, and if it moved, re-exec
|
||||||
@@ -151,7 +172,7 @@ parse_kv_file() {
|
|||||||
# warns (but continues on the current checkout) if the pull fails for any
|
# warns (but continues on the current checkout) if the pull fails for any
|
||||||
# other reason (offline, local changes that prevent a fast-forward). Skip
|
# other reason (offline, local changes that prevent a fast-forward). Skip
|
||||||
# entirely with SKIP_SELF_UPDATE=1.
|
# entirely with SKIP_SELF_UPDATE=1.
|
||||||
if [[ "${SKIP_SELF_UPDATE:-0}" != "1" && "${THETA_ENV_REEXECED:-0}" != "1" ]] \
|
if [[ "${SKIP_SELF_UPDATE:-0}" != "1" && "${THETA_SUITE_REEXECED:-0}" != "1" ]] \
|
||||||
&& command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
&& command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
||||||
&& git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
|
&& git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
|
||||||
then
|
then
|
||||||
@@ -161,11 +182,11 @@ then
|
|||||||
AFTER_REV="$(git rev-parse HEAD)"
|
AFTER_REV="$(git rev-parse HEAD)"
|
||||||
if [[ "$BEFORE_REV" != "$AFTER_REV" ]]; then
|
if [[ "$BEFORE_REV" != "$AFTER_REV" ]]; then
|
||||||
AFTER_VER="$(git describe --tags "$AFTER_REV" 2>/dev/null || echo "${AFTER_REV:0:12}")"
|
AFTER_VER="$(git describe --tags "$AFTER_REV" 2>/dev/null || echo "${AFTER_REV:0:12}")"
|
||||||
info "Updated theta-env (${BEFORE_VER} -> ${AFTER_VER}) — restarting setup.sh with the new version..."
|
info "Updated theta-suite (${BEFORE_VER} -> ${AFTER_VER}) — restarting setup.sh with the new version..."
|
||||||
THETA_ENV_REEXECED=1 exec "$0" "$@"
|
THETA_SUITE_REEXECED=1 exec "$0" "$@"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "Could not fast-forward theta-env to the latest upstream (offline, or local changes) — continuing with the current checkout."
|
warn "Could not fast-forward theta-suite to the latest upstream (offline, or local changes) — continuing with the current checkout."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -177,6 +198,8 @@ fi
|
|||||||
# resolved in ensure_config; this is only the hostname override.
|
# resolved in ensure_config; this is only the hostname override.
|
||||||
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
|
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
|
||||||
export CFG_JUMP_HOST
|
export CFG_JUMP_HOST
|
||||||
|
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
||||||
|
export CFG_CREATE_ALL_HTTP
|
||||||
|
|
||||||
# ── Optional outbound HTTP(S) proxy for docker build + the running containers ─
|
# ── Optional outbound HTTP(S) proxy for docker build + the running containers ─
|
||||||
# CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY (from ./setup.env or the
|
# CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY (from ./setup.env or the
|
||||||
@@ -269,6 +292,21 @@ dn_from_domain() {
|
|||||||
echo "dc=$1" | sed 's/\./,dc=/g'
|
echo "dc=$1" | sed 's/\./,dc=/g'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Read a value from the (operator-owned) ./config/sso-secrets.js -- the source of
|
||||||
|
# truth on re-runs, where the CFG_* first-run shell vars are not (re)derived
|
||||||
|
# (ensure_config returns early once sso-secrets.js exists). Reads `stack.<key>`.
|
||||||
|
# Prints empty on any failure. Usage: sso_secrets_get ldapBaseDn
|
||||||
|
sso_secrets_get() {
|
||||||
|
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c.stack&&c.stack[k]!=null?c.stack[k]:"")' \
|
||||||
|
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read a top-level (non-stack) secret from sso-secrets.js, e.g. serviceAccountPass.
|
||||||
|
sso_secrets_get_top() {
|
||||||
|
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c[k]!=null?c[k]:"")' \
|
||||||
|
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
# Write ./config/sso-secrets.js from the CFG_* shell vars.
|
# Write ./config/sso-secrets.js from the CFG_* shell vars.
|
||||||
write_sso_secrets() {
|
write_sso_secrets() {
|
||||||
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
|
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
|
||||||
@@ -442,6 +480,7 @@ BAOEOF
|
|||||||
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
|
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
|
||||||
CFG_SVC_PASS="${CFG_SVC_PASS:-}"
|
CFG_SVC_PASS="${CFG_SVC_PASS:-}"
|
||||||
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
|
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
|
||||||
|
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
||||||
|
|
||||||
# ── One-time migration from .env / proxy.env (existing deployments) ──
|
# ── One-time migration from .env / proxy.env (existing deployments) ──
|
||||||
# Preserve the operator's existing secrets so the running deployment keeps
|
# Preserve the operator's existing secrets so the running deployment keeps
|
||||||
@@ -565,7 +604,7 @@ backup_before_rebuild() {
|
|||||||
# bind-mount was added), then fall back to reading it inside the container.
|
# bind-mount was added), then fall back to reading it inside the container.
|
||||||
# Use `docker exec <name>` (not `docker-compose exec`) so the snapshot works
|
# Use `docker exec <name>` (not `docker-compose exec`) so the snapshot works
|
||||||
# no matter which compose project brought the container up — the unified
|
# no matter which compose project brought the container up — the unified
|
||||||
# theta-env stack (project "theta-env") and the standalone submodule stack
|
# theta-suite stack (project "theta-suite") and the standalone submodule stack
|
||||||
# (project "sso-manager-node") both name it "sso-manager". `docker-compose
|
# (project "sso-manager-node") both name it "sso-manager". `docker-compose
|
||||||
# exec` from the superproject otherwise exits 1 silently (wrong project) and
|
# exec` from the superproject otherwise exits 1 silently (wrong project) and
|
||||||
# the snapshot silently no-ops.
|
# the snapshot silently no-ops.
|
||||||
@@ -617,7 +656,7 @@ backup_before_rebuild() {
|
|||||||
docker exec "$svc" redis-cli BGSAVE >/dev/null 2>&1 || true
|
docker exec "$svc" redis-cli BGSAVE >/dev/null 2>&1 || true
|
||||||
ok=0
|
ok=0
|
||||||
for i in $(seq 1 10); do
|
for i in $(seq 1 10); do
|
||||||
if [[ "$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9')" -gt "$before" ]]; then
|
if [[ "$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)" -gt "$before" ]]; then
|
||||||
ok=1; break
|
ok=1; break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
@@ -629,8 +668,8 @@ backup_before_rebuild() {
|
|||||||
fi
|
fi
|
||||||
# Redis writes dump.rdb to `dir`/`dbfilename`; ask it where that is so the
|
# Redis writes dump.rdb to `dir`/`dbfilename`; ask it where that is so the
|
||||||
# copy works across the unified (/data) and standalone (/app) layouts.
|
# copy works across the unified (/data) and standalone (/app) layouts.
|
||||||
rdir="$(docker exec "$svc" redis-cli CONFIG GET dir 2>/dev/null | sed -n '2p' | tr -d '\r\n')"
|
rdir="$(docker exec "$svc" redis-cli CONFIG GET dir 2>/dev/null | sed -n '2p' | tr -d '\r\n' || true)"
|
||||||
rfile="$(docker exec "$svc" redis-cli CONFIG GET dbfilename 2>/dev/null | sed -n '2p' | tr -d '\r\n')"
|
rfile="$(docker exec "$svc" redis-cli CONFIG GET dbfilename 2>/dev/null | sed -n '2p' | tr -d '\r\n' || true)"
|
||||||
rpath="${rdir:+$rdir/}${rfile:-dump.rdb}"
|
rpath="${rdir:+$rdir/}${rfile:-dump.rdb}"
|
||||||
if [[ "$ok" == "1" ]] && docker cp "$svc:$rpath" "$dir/$svc.rdb" >/dev/null 2>&1; then
|
if [[ "$ok" == "1" ]] && docker cp "$svc:$rpath" "$dir/$svc.rdb" >/dev/null 2>&1; then
|
||||||
info " Redis ($svc) -> $svc.rdb"
|
info " Redis ($svc) -> $svc.rdb"
|
||||||
@@ -660,6 +699,20 @@ backup_before_rebuild() {
|
|||||||
backup_before_rebuild
|
backup_before_rebuild
|
||||||
|
|
||||||
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
|
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
|
||||||
|
# Full reset (--reset-openbao): stop/remove openbao, drop the data volume, and
|
||||||
|
# delete the init/keys file (incl. any backup copy that setup.sh would otherwise
|
||||||
|
# restore). The normal bootstrap below then initializes a brand-new store, so no
|
||||||
|
# stale policy content or token survives.
|
||||||
|
if [[ "$RESET_OPENBAO" == "1" ]]; then
|
||||||
|
info "── Full OpenBao reset requested (--reset-openbao) ──"
|
||||||
|
"${COMPOSE[@]}" stop openbao >/dev/null 2>&1 || true
|
||||||
|
"${COMPOSE[@]}" rm -f openbao >/dev/null 2>&1 || true
|
||||||
|
docker volume ls -q 2>/dev/null | grep '^openbao' | xargs -r docker volume rm >/dev/null 2>&1 || true
|
||||||
|
rm -f "$CONFIG_DIR/bao-init.json"
|
||||||
|
rm -f ./backups/bao-init.json ./backups/*/bao-init.json 2>/dev/null || true
|
||||||
|
info " openbao volume + bao-init.json cleared; will re-initialize fresh."
|
||||||
|
fi
|
||||||
|
|
||||||
info "Starting openbao..."
|
info "Starting openbao..."
|
||||||
"${COMPOSE[@]}" run --rm --user root openbao chown -R 100:1000 /vault/data
|
"${COMPOSE[@]}" run --rm --user root openbao chown -R 100:1000 /vault/data
|
||||||
"${COMPOSE[@]}" up -d openbao
|
"${COMPOSE[@]}" up -d openbao
|
||||||
@@ -672,7 +725,17 @@ for i in $(seq 1 30); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
if ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true' || true; then
|
# If config/bao-init.json is missing, search backups for a saved copy
|
||||||
|
if [[ ! -f "$CONFIG_DIR/bao-init.json" ]]; then
|
||||||
|
latest_backup_init=$(find ./backups -name "bao-init.json" 2>/dev/null | sort -r | head -n1 || true)
|
||||||
|
if [[ -n "$latest_backup_init" && -f "$latest_backup_init" ]]; then
|
||||||
|
info "Restoring $CONFIG_DIR/bao-init.json from backup ($latest_backup_init)..."
|
||||||
|
cp "$latest_backup_init" "$CONFIG_DIR/bao-init.json"
|
||||||
|
chmod 600 "$CONFIG_DIR/bao-init.json"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true'; then
|
||||||
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
|
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
|
||||||
if ! echo "$status_json" | grep -q '"initialized": true'; then
|
if ! echo "$status_json" | grep -q '"initialized": true'; then
|
||||||
info "Initializing openbao for the first time..."
|
info "Initializing openbao for the first time..."
|
||||||
@@ -684,13 +747,54 @@ fi
|
|||||||
|
|
||||||
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
|
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
|
||||||
if echo "$status_json" | grep -q '"sealed": true'; then
|
if echo "$status_json" | grep -q '"sealed": true'; then
|
||||||
info "Unsealing openbao..."
|
UNSEAL_KEY=""
|
||||||
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
|
if [[ -f "$CONFIG_DIR/bao-init.json" ]]; then
|
||||||
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
|
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" 2>/dev/null | tail -n1 | cut -d'"' -f2 || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$UNSEAL_KEY" ]]; then
|
||||||
|
UNSEAL_KEY="$(env_get VAULT_UNSEAL_KEY)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$UNSEAL_KEY" ]]; then
|
||||||
|
info "Unsealing openbao..."
|
||||||
|
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
|
||||||
|
else
|
||||||
|
warn "OpenBao is sealed with an unrecoverable key. Resetting OpenBao volume and re-initializing..."
|
||||||
|
"${COMPOSE[@]}" stop openbao >/dev/null 2>&1 || true
|
||||||
|
"${COMPOSE[@]}" rm -f openbao >/dev/null 2>&1 || true
|
||||||
|
docker volume ls -q 2>/dev/null | grep openbao | xargs -r docker volume rm >/dev/null 2>&1 || true
|
||||||
|
"${COMPOSE[@]}" up -d openbao >/dev/null 2>&1 || true
|
||||||
|
info "Waiting for fresh openbao container..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
info "Initializing fresh openbao..."
|
||||||
|
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
|
||||||
|
chmod 600 "$CONFIG_DIR/bao-init.json"
|
||||||
|
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
|
||||||
|
info "Unsealing fresh openbao..."
|
||||||
|
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export VAULT_TOKEN
|
export VAULT_TOKEN
|
||||||
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" | cut -d'"' -f4)
|
if [[ -f "$CONFIG_DIR/bao-init.json" ]]; then
|
||||||
|
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" 2>/dev/null | cut -d'"' -f4 || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$VAULT_TOKEN" ]]; then
|
||||||
|
VAULT_TOKEN="$(env_get VAULT_TOKEN)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$VAULT_TOKEN" ]]; then
|
||||||
|
die "Could not determine OpenBao VAULT_TOKEN from $CONFIG_DIR/bao-init.json or .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# UNSEAL_KEY is only set when OpenBao needed unsealing this run; on a re-run of
|
||||||
|
# an already-unsealed store it is unset, so guard with ${UNSEAL_KEY:-} (set -u).
|
||||||
|
if [[ -n "${UNSEAL_KEY:-}" ]]; then
|
||||||
|
env_upsert VAULT_UNSEAL_KEY "$UNSEAL_KEY"
|
||||||
|
fi
|
||||||
env_upsert VAULT_TOKEN "$VAULT_TOKEN"
|
env_upsert VAULT_TOKEN "$VAULT_TOKEN"
|
||||||
|
|
||||||
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=json 2>/dev/null | grep -q '"secret/":'; then
|
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=json 2>/dev/null | grep -q '"secret/":'; then
|
||||||
@@ -712,41 +816,43 @@ bao_run() { docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao "$@"; }
|
|||||||
# Write an ACL policy from stdin HCL only if it does not already exist.
|
# Write an ACL policy from stdin HCL only if it does not already exist.
|
||||||
ensure_policy() {
|
ensure_policy() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
if bao_run policy read "$name" >/dev/null 2>&1; then
|
# Always (re)write: `bao policy write` is an idempotent overwrite, so this
|
||||||
info " policy ${name} already exists — keeping."
|
# applies policy edits on a re-run instead of stranding the old HCL
|
||||||
else
|
# forever ("already exists — keeping" silently dropped upgrades — e.g.
|
||||||
info " writing policy ${name}..."
|
# the secret/metadata mount-root list grant added for the /vault fix).
|
||||||
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null
|
info " writing policy ${name}..."
|
||||||
fi
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read KEY= from ./.env (empty if absent) — reuse a previously minted token
|
|
||||||
# instead of minting a fresh one on every setup.sh run.
|
|
||||||
env_get() {
|
|
||||||
local key="$1" file=./.env
|
|
||||||
[[ -f "$file" ]] || return 0
|
|
||||||
# `|| true` is load-bearing: under `set -euo pipefail`, a no-match `grep`
|
|
||||||
# exits 1 and (pipefail) makes the whole pipeline return 1. Callers do
|
|
||||||
# `existing="$(env_get ...)"` as a bare assignment — a non-zero return there
|
|
||||||
# trips `set -e` and silently kills the whole script (this is exactly what
|
|
||||||
# aborted a fresh install right after "Minting per-app OpenBao tokens": the
|
|
||||||
# root VAULT_TOKEN env_upsert had already created .env, but the app-token
|
|
||||||
# keys were absent, so the first env_get returned 1). "Key absent" is the
|
|
||||||
# normal path here, so always return 0 with empty output.
|
|
||||||
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mint an orphan, renewable token for `policy` and persist it to .env as `key`,
|
# Mint a PERIODIC service token (theta-svc role: orphan, renewable, 768h
|
||||||
# OR reuse the token already in .env if it is still valid (re-mint on expiry).
|
# period) for `policy` and persist it to .env as `key`. Periodic tokens have no
|
||||||
|
# max-TTL death date — each renewal resets the clock — unlike the plain orphan
|
||||||
|
# tokens minted before this (creation_ttl 768h, dead ~32 days after mint no
|
||||||
|
# matter what). The bao-renewer sidecar renews them every 12h while the stack
|
||||||
|
# runs, and every setup.sh re-run renews here too. A valid-but-non-periodic
|
||||||
|
# token from an older setup.sh is revoked and re-minted as periodic.
|
||||||
ensure_token() {
|
ensure_token() {
|
||||||
local key="$1" policy="$2" existing tok
|
local key="$1" policy="$2" existing tok lookup
|
||||||
existing="$(env_get "$key")"
|
existing="$(env_get "$key")"
|
||||||
if [[ -n "$existing" ]] && docker exec -e BAO_TOKEN="$existing" openbao bao token lookup >/dev/null 2>&1; then
|
if [[ -n "$existing" ]]; then
|
||||||
info " ${key} already minted + valid — keeping."
|
lookup="$(docker exec -e BAO_TOKEN="$existing" openbao bao token lookup -format=json 2>/dev/null || true)"
|
||||||
return 0
|
if [[ -n "$lookup" ]]; then
|
||||||
|
# Periodic = minted through the theta-svc role. (OpenBao token lookup
|
||||||
|
# does not expose a `period` field — the role is the reliable marker;
|
||||||
|
# renewal behavior confirms the 768h period resets past the original
|
||||||
|
# creation TTL.)
|
||||||
|
if echo "$lookup" | grep -q '"role": *"theta-svc"'; then
|
||||||
|
info " ${key} already minted + periodic (theta-svc) — renewing to reset its clock."
|
||||||
|
docker exec -e BAO_TOKEN="$existing" openbao bao token renew >/dev/null 2>&1 || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
info " ${key} is valid but NOT periodic (pre-theta-svc mint; dies at its max TTL) — revoking + re-minting."
|
||||||
|
bao_run token revoke "$existing" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
info " minting ${key} (policy=${policy})..."
|
info " minting ${key} (policy=${policy}, role=theta-svc, periodic 768h)..."
|
||||||
tok="$(bao_run token create -policy="$policy" -orphan=true -field=token)" \
|
tok="$(bao_run token create -role=theta-svc -policy="$policy" -field=token)" \
|
||||||
|| die "failed to mint ${key} (policy=${policy})"
|
|| die "failed to mint ${key} (policy=${policy})"
|
||||||
env_upsert "$key" "$tok"
|
env_upsert "$key" "$tok"
|
||||||
}
|
}
|
||||||
@@ -769,27 +875,44 @@ seed_app_conf() {
|
|||||||
info "Configuring OpenBao policies..."
|
info "Configuring OpenBao policies..."
|
||||||
# sso-broker — sso's authority to read/write its own conf, mint per-user and
|
# sso-broker — sso's authority to read/write its own conf, mint per-user and
|
||||||
# per-app tokens (auth/token/create/sso-broker), and create the matching
|
# per-app tokens (auth/token/create/sso-broker), and create the matching
|
||||||
# user-<uid> / app-<name> / sso-admin policies.
|
# user-<uid> / app-<name> / sso-admin policies. secret/plugins/* holds per-instance
|
||||||
|
# plugin secrets managed by the SSO plugin system (configurable plugin copies,
|
||||||
|
# loaded/unloaded at runtime — see sso-manager-node docs/plugins.md).
|
||||||
ensure_policy sso-broker <<'HCL'
|
ensure_policy sso-broker <<'HCL'
|
||||||
path "secret/data/sso-manager/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/data/sso-manager/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/sso-manager/conf" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/sso-manager/conf" { capabilities = ["list", "read", "delete"] }
|
||||||
|
path "secret/data/proxy/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/proxy/conf" { capabilities = ["list", "read", "delete"] }
|
||||||
path "secret/data/users/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/data/users/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/users/*" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/users/*" { capabilities = ["list", "read", "delete"] }
|
||||||
path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
||||||
|
path "secret/data/plugins/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
|
||||||
path "auth/token/create/sso-broker" { capabilities = ["update"] }
|
path "auth/token/create/sso-broker" { capabilities = ["update"] }
|
||||||
|
path "auth/token/create/sso-app" { capabilities = ["update"] }
|
||||||
|
path "auth/token/renew-accessor" { capabilities = ["update"] }
|
||||||
|
path "auth/token/revoke-accessor" { capabilities = ["update"] }
|
||||||
|
path "auth/token/lookup-accessor" { capabilities = ["update"] }
|
||||||
path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
HCL
|
HCL
|
||||||
# sso-admin — admin users in the vault UI: read/write/list everything under secret/.
|
# sso-admin — admin users in the vault UI: read/write/list everything under secret/.
|
||||||
|
# The bare `secret/metadata` grant lets an admin LIST the KV mount root (the
|
||||||
|
# top-level dirs); `secret/metadata/*` covers nested paths but not the root
|
||||||
|
# itself, so without it the /vault secrets list 403s.
|
||||||
ensure_policy sso-admin <<'HCL'
|
ensure_policy sso-admin <<'HCL'
|
||||||
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
|
||||||
|
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
|
||||||
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }
|
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }
|
||||||
HCL
|
HCL
|
||||||
# proxy / jump-host — read only their own boot conf.
|
# proxy / jump-host — read only their own boot conf.
|
||||||
ensure_policy proxy <<'HCL'
|
ensure_policy proxy <<'HCL'
|
||||||
path "secret/data/proxy/conf" { capabilities = ["read"] }
|
path "secret/data/proxy/conf" { capabilities = ["read"] }
|
||||||
|
path "secret/data/proxy/dns-providers/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/proxy/dns-providers/*" { capabilities = ["list", "read", "delete"] }
|
||||||
path "secret/metadata/proxy/conf" { capabilities = ["read", "list"] }
|
path "secret/metadata/proxy/conf" { capabilities = ["read", "list"] }
|
||||||
HCL
|
HCL
|
||||||
ensure_policy jump-host <<'HCL'
|
ensure_policy jump-host <<'HCL'
|
||||||
@@ -809,15 +932,48 @@ else
|
|||||||
info " token role sso-broker already exists — keeping."
|
info " token role sso-broker already exists — keeping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# sso-app token role: external-app tokens minted from the sso vault UI. Periodic
|
||||||
|
# 768h (NOT the broker's 24h) — an app token is a long-lived credential; with a
|
||||||
|
# 24h period any downstream app that didn't renew daily silently died. A 768h
|
||||||
|
# period keeps it alive as long as the app renews (or is re-minted) at least
|
||||||
|
# monthly: `bao token renew-self` / POST /v1/auth/token/renew-self.
|
||||||
|
info "Configuring sso-app token role..."
|
||||||
|
if ! bao_run read auth/token/roles/sso-app >/dev/null 2>&1; then
|
||||||
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-app - <<'JSON' >/dev/null
|
||||||
|
{"allowed_policies_glob":["app-*"],"orphan":true,"renewable":true,"token_period":"768h"}
|
||||||
|
JSON
|
||||||
|
else
|
||||||
|
info " token role sso-app already exists — keeping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# theta-svc token role: the services' own tokens (SSO/PROXY/JUMP_VAULT_TOKEN).
|
||||||
|
# Periodic 768h so they can be renewed forever (the bao-renewer sidecar renews
|
||||||
|
# every 12h; each setup.sh re-run renews too). allowed_policies is exact-match:
|
||||||
|
# exactly the three service policies, nothing else.
|
||||||
|
info "Configuring theta-svc token role..."
|
||||||
|
if ! bao_run read auth/token/roles/theta-svc >/dev/null 2>&1; then
|
||||||
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/theta-svc - <<'JSON' >/dev/null
|
||||||
|
{"allowed_policies":["sso-broker","proxy","jump-host"],"orphan":true,"renewable":true,"token_period":"768h"}
|
||||||
|
JSON
|
||||||
|
else
|
||||||
|
info " token role theta-svc already exists — keeping."
|
||||||
|
fi
|
||||||
|
|
||||||
info "Minting per-app OpenBao tokens (stored in .env, passed to containers as VAULT_TOKEN)..."
|
info "Minting per-app OpenBao tokens (stored in .env, passed to containers as VAULT_TOKEN)..."
|
||||||
ensure_token SSO_VAULT_TOKEN sso-broker
|
ensure_token SSO_VAULT_TOKEN sso-broker
|
||||||
ensure_token PROXY_VAULT_TOKEN proxy
|
ensure_token PROXY_VAULT_TOKEN proxy
|
||||||
ensure_token JUMP_VAULT_TOKEN jump-host
|
ensure_token JUMP_VAULT_TOKEN jump-host
|
||||||
|
|
||||||
info "OpenBao secrets configured:"
|
info "OpenBao secrets configured:"
|
||||||
info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)"
|
info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)"
|
||||||
info " token role: sso-broker (mints user-*/app-*/sso-admin tokens, 24h period)"
|
info " token roles: sso-broker (user-*/app-*/sso-admin, 24h period), sso-app (app-*, 768h period), theta-svc (service tokens, 768h period)"
|
||||||
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env"
|
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env (periodic; renewed by bao-renewer)"
|
||||||
|
|
||||||
|
# bao-renewer: renews the three periodic service tokens every 12h so they never
|
||||||
|
# hit their period boundary while the stack is running. Recreated (not just
|
||||||
|
# started) so it always picks up freshly re-minted tokens from .env.
|
||||||
|
info "Starting bao-renewer (service-token renewal sidecar)..."
|
||||||
|
"${COMPOSE[@]}" up -d --force-recreate bao-renewer
|
||||||
|
|
||||||
# ── 4. Start SSO Manager, wait for health ─────────────────────────────────────
|
# ── 4. Start SSO Manager, wait for health ─────────────────────────────────────
|
||||||
# SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a
|
# SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a
|
||||||
@@ -843,6 +999,18 @@ for i in $(seq 1 60); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# After a full OpenBao reset, the Redis-cached per-user/admin vault tokens (in
|
||||||
|
# the persisted sso-data volume) reference the old, now-wiped store — drop them
|
||||||
|
# so the broker re-mints fresh tokens against the new instance. Belt-and-
|
||||||
|
# suspenders: the broker also always reconciles policy content before serving a
|
||||||
|
# token, but a token minted by the previous OpenBao instance is simply invalid
|
||||||
|
# there, so a cache flush is required after a reset.
|
||||||
|
if [[ "$RESET_OPENBAO" == "1" ]]; then
|
||||||
|
info " clearing cached vault tokens (old OpenBao instance)..."
|
||||||
|
docker exec sso-manager sh -c "redis-cli EVAL \"for _,k in ipairs(redis.call('keys','vault_token:*')) do redis.call('del',k) end\" 0" \
|
||||||
|
>/dev/null 2>&1 || warn " could not flush Redis vault-token cache (will re-mint on next access)"
|
||||||
|
fi
|
||||||
|
|
||||||
info "Seeding app configs into OpenBao (idempotent)..."
|
info "Seeding app configs into OpenBao (idempotent)..."
|
||||||
# sso-manager/conf holds the operator-set LDAP/SMTP/jwtSecret values — sso has
|
# sso-manager/conf holds the operator-set LDAP/SMTP/jwtSecret values — sso has
|
||||||
# no bootstrap-generated creds, so the file is the complete source of truth.
|
# no bootstrap-generated creds, so the file is the complete source of truth.
|
||||||
@@ -963,7 +1131,7 @@ async function ensureHost(host, ip, targetPort) {
|
|||||||
host: host,
|
host: host,
|
||||||
ip: ip,
|
ip: ip,
|
||||||
targetPort: targetPort,
|
targetPort: targetPort,
|
||||||
forcessl: true,
|
forcessl: $( [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && echo false || echo true ),
|
||||||
targetssl: false,
|
targetssl: false,
|
||||||
sso_enabled: false,
|
sso_enabled: false,
|
||||||
created_by: 'setup.sh',
|
created_by: 'setup.sh',
|
||||||
@@ -1020,7 +1188,7 @@ const {Host} = require('/app/models').models;
|
|||||||
try { await Host.get($(js_str "$JUMP_HOST")); console.log('SKIP ${JUMP_HOST} (already exists)'); }
|
try { await Host.get($(js_str "$JUMP_HOST")); console.log('SKIP ${JUMP_HOST} (already exists)'); }
|
||||||
catch (e) {
|
catch (e) {
|
||||||
if (e.name !== 'EntryNotFound') throw e;
|
if (e.name !== 'EntryNotFound') throw e;
|
||||||
await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: true, targetssl: false, sso_enabled: false, created_by: 'setup.sh' });
|
await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: $( [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && echo false || echo true ), targetssl: false, sso_enabled: false, created_by: 'setup.sh' });
|
||||||
console.log('CREATED ${JUMP_HOST} -> jump-host:3002');
|
console.log('CREATED ${JUMP_HOST} -> jump-host:3002');
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -1030,9 +1198,156 @@ NODEEOF
|
|||||||
)
|
)
|
||||||
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
|
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
|
||||||
|
|
||||||
|
# ── 7c. Install theta-agent on the host ──────────────────────────────────────
|
||||||
|
# Controlled by CFG_THETA_AGENT_ENABLE (default: 1 = enabled)
|
||||||
|
CFG_THETA_AGENT_ENABLE="${CFG_THETA_AGENT_ENABLE:-1}"
|
||||||
|
if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
||||||
|
info "Setting up theta-agent on the host..."
|
||||||
|
(
|
||||||
|
cd theta-agent || exit 0
|
||||||
|
# Install the prebuilt binary that ships in the theta-agent submodule (the
|
||||||
|
# repo's own install.sh uses the same release binary). We do NOT build from
|
||||||
|
# source here: a previous `go build -o theta-agent main.go websocket.go
|
||||||
|
# config.go` omitted executor.go/telemetry.go, failed to compile, and was
|
||||||
|
# silently skipped, so the agent was never installed.
|
||||||
|
if [[ ! -f "theta-agent-linux-amd64" ]]; then
|
||||||
|
warn "Prebuilt theta-agent-linux-amd64 missing from the theta-agent submodule. Skipping theta-agent installation."
|
||||||
|
else
|
||||||
|
info " Installing prebuilt theta-agent binary..."
|
||||||
|
if [[ -x "theta-agent-linux-amd64" ]]; then
|
||||||
|
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
|
||||||
|
sudo mkdir -p /etc/theta42
|
||||||
|
if [[ ! -f /etc/theta42/agent.yml ]]; then
|
||||||
|
sudo cp agent.yml.example /etc/theta42/agent.yml
|
||||||
|
AGENT_TOKEN="$(rand_hex 16)"
|
||||||
|
sudo sed -i "s/REPLACE_WITH_AGENT_TOKEN/$AGENT_TOKEN/" /etc/theta42/agent.yml
|
||||||
|
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
|
||||||
|
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
|
||||||
|
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||||
|
else
|
||||||
|
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Stop a running agent before overwriting its binary (cp into a
|
||||||
|
# running executable fails with "Text file busy" on a re-install).
|
||||||
|
sudo systemctl stop theta-agent.service 2>/dev/null || true
|
||||||
|
sudo cp theta-agent-linux-amd64 /usr/local/bin/theta-agent
|
||||||
|
sudo chmod +x /usr/local/bin/theta-agent
|
||||||
|
|
||||||
|
sudo bash -c "cat <<'EOF' > /etc/systemd/system/theta-agent.service
|
||||||
|
[Unit]
|
||||||
|
Description=Theta Agent
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/theta-agent
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now theta-agent.service
|
||||||
|
info " theta-agent installed and started."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
else
|
||||||
|
info "theta-agent installation skipped (CFG_THETA_AGENT_ENABLE=0)."
|
||||||
|
fi
|
||||||
|
# ── 7d. Configure theta-agent integration with this host ─────────────────────
|
||||||
|
# Non-interactive configuration driven by setup.env variables:
|
||||||
|
# CFG_THETA_AGENT_ENABLE (default: 1) - Install/configure theta-agent
|
||||||
|
# CFG_THETA_AGENT_LDAP_AUTH (default: 1) - Configure LDAP authentication via ldap-client
|
||||||
|
# CFG_THETA_AGENT_FULL_CONTROL (default: 1) - Enable all agent capabilities
|
||||||
|
# Only runs if theta-agent was installed (section 7c) or already exists.
|
||||||
|
if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]] && [[ -x /usr/local/bin/theta-agent ]]; then
|
||||||
|
info "Configuring theta-agent integration with this host..."
|
||||||
|
|
||||||
|
# Default to enabled unless explicitly disabled
|
||||||
|
CFG_THETA_AGENT_LDAP_AUTH="${CFG_THETA_AGENT_LDAP_AUTH:-1}"
|
||||||
|
CFG_THETA_AGENT_FULL_CONTROL="${CFG_THETA_AGENT_FULL_CONTROL:-1}"
|
||||||
|
|
||||||
|
if [[ "$CFG_THETA_AGENT_LDAP_AUTH" == "1" ]]; then
|
||||||
|
info " Configuring LDAP authentication for this host..."
|
||||||
|
|
||||||
|
# ldap-client/index.sh refuses to run without ./ldap.vars, which is
|
||||||
|
# gitignored and never shipped in the checkout (it holds a real bind
|
||||||
|
# password). On the agent-enrollment path we generate it from the stack's
|
||||||
|
# own config so the host can actually enroll; an operator-provided
|
||||||
|
# ldap.vars (cp ldap.vars.template ldap.vars + edit) is always kept.
|
||||||
|
if [[ ! -f ldap-client/ldap.vars ]]; then
|
||||||
|
info " Generating ldap-client/ldap.vars from the stack config..."
|
||||||
|
# CFG_* first-run vars may be unset on a re-run (ensure_config returns
|
||||||
|
# early once sso-secrets.js exists), so fall back to reading the real
|
||||||
|
# values from the operator-owned sso-secrets.js. All `:-` guarded so a
|
||||||
|
# missing value degrades to an empty ldap.vars field, not a set -u abort.
|
||||||
|
ldap_base_dn="${CFG_BASE_DN:-$(sso_secrets_get ldapBaseDn)}"
|
||||||
|
ldap_site="${CFG_SITE_NAME:-$(sso_secrets_get siteName)}"
|
||||||
|
ldap_bind_pass="${CFG_SVC_PASS:-$(sso_secrets_get_top serviceAccountPass)}"
|
||||||
|
sso_host="${CFG_SSO_HOST:-$(sso_secrets_get ssoHost)}"
|
||||||
|
# The LDAP server is co-located with the stack on THIS host, so the
|
||||||
|
# host must reach it over the loopback / a local address -- NEVER the
|
||||||
|
# public domain (sso.<domain>), which cannot route back to the 389/636
|
||||||
|
# ports through NAT. localhost is fine because the generated sssd.conf
|
||||||
|
# sets ldap_tls_reqcert=never (hostname verification is off). An
|
||||||
|
# operator may override with CFG_LDAPS_HOST (an internal hostname/IP).
|
||||||
|
ldaps_host="${CFG_LDAPS_HOST:-localhost}"
|
||||||
|
cat > ldap-client/ldap.vars <<LDAPVARS
|
||||||
|
export ldap_host="${ldaps_host}"
|
||||||
|
export ldap_base_dn="${ldap_base_dn}"
|
||||||
|
export ldap_bind_dn="cn=ldapclient,ou=people,${ldap_base_dn}"
|
||||||
|
export ldap_bind_password="${ldap_bind_pass}"
|
||||||
|
export sso_url="https://${sso_host}"
|
||||||
|
export sso_token=""
|
||||||
|
export ldap_location="${ldap_site:-local}"
|
||||||
|
# Groups that grant SSH/access on this host (docs/GROUPS.md §8): the site's
|
||||||
|
# all-hosts aggregate, this host's own access group, and god_admin.
|
||||||
|
ldap_access_groups=( "site_\${ldap_location}_hosts_access" "site_\${ldap_location}_host_\$(hostname)_access" "god_admin" )
|
||||||
|
LDAPVARS
|
||||||
|
else
|
||||||
|
info " ldap-client/ldap.vars exists -- keeping it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd ldap-client || exit 0
|
||||||
|
if [[ -x "index.sh" ]]; then
|
||||||
|
bash index.sh --non-interactive 2>/dev/null || warn " ldap-client enrollment failed (continuing)..."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
else
|
||||||
|
info " LDAP authentication configuration skipped (CFG_THETA_AGENT_LDAP_AUTH=0)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CFG_THETA_AGENT_FULL_CONTROL" == "1" ]]; then
|
||||||
|
info " Configuring theta-agent with full host control capabilities..."
|
||||||
|
if [[ -f /etc/theta42/agent.yml ]]; then
|
||||||
|
sudo sed -i 's/arbitrary_bash: false/arbitrary_bash: true/' /etc/theta42/agent.yml
|
||||||
|
# service_control is a []string allowlist (NOT a bool) — setting it to
|
||||||
|
# `true` makes the agent fail YAML decode and crash-loop. There is no
|
||||||
|
# wildcard; leave the operator's list (or the [] default = deny all)
|
||||||
|
# alone and document how to enable specific services.
|
||||||
|
# sudo sed -i 's/service_control: .*/service_control: true/' ...
|
||||||
|
sudo sed -i 's/reboot: false/reboot: true/' /etc/theta42/agent.yml
|
||||||
|
sudo sed -i 's/configure_ldap: false/configure_ldap: true/' /etc/theta42/agent.yml
|
||||||
|
info " (service_control left as its allowlist; set e.g. service_control: [\"nginx\"] in /etc/theta42/agent.yml to permit managing specific services)"
|
||||||
|
info " theta-agent full control enabled. Restarting service..."
|
||||||
|
sudo systemctl restart theta-agent.service
|
||||||
|
else
|
||||||
|
warn " /etc/theta42/agent.yml not found. Full control not configured."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info " theta-agent running with limited capabilities (CFG_THETA_AGENT_FULL_CONTROL=0)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info " theta-agent configuration skipped (agent not installed or CFG_THETA_AGENT_ENABLE=0)."
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 8. Summary ───────────────────────────────────────────────────────────────
|
# ── 8. Summary ───────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
|
printf '\033[1;34m[setup]\033[0m \033[1;32mDone. Your SSO + proxy stack is up.\033[0m\n'
|
||||||
echo
|
echo
|
||||||
echo " SSO Manager UI: https://${SSO_HOST} (fronted by the proxy under TLS)"
|
echo " SSO Manager UI: https://${SSO_HOST} (fronted by the proxy under TLS)"
|
||||||
echo " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}"
|
echo " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}"
|
||||||
@@ -1044,7 +1359,7 @@ echo " Jump host (web): https://${JUMP_HOST:-jump.${SSO_HOST#sso.}} (audit
|
|||||||
echo
|
echo
|
||||||
echo " First admin login credentials are in ./config/sso-secrets.js:"
|
echo " First admin login credentials are in ./config/sso-secrets.js:"
|
||||||
echo " user: ${ADMIN_UID}"
|
echo " user: ${ADMIN_UID}"
|
||||||
echo " pass: bootstrap.adminPass"
|
echo " pass: ${CFG_ADMIN_PASS:-<see ./config/sso-secrets.js>}"
|
||||||
echo
|
echo
|
||||||
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
|
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
|
||||||
echo " user: proxyadmin2"
|
echo " user: proxyadmin2"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "=== Starting theta-env Integration Tests ==="
|
echo "=== Starting theta-suite Integration Tests ==="
|
||||||
|
|
||||||
echo "=> Cleaning up any existing containers and volumes..."
|
echo "=> Cleaning up any existing containers and volumes..."
|
||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
@@ -11,7 +11,7 @@ echo "=> Running setup.sh to initialize environment..."
|
|||||||
# setup.sh uses dialog, which requires a terminal, but it falls back to defaults if not interactive?
|
# setup.sh uses dialog, which requires a terminal, but it falls back to defaults if not interactive?
|
||||||
# Actually setup.sh has a dialog UI. Let's just run it or provide a seeded config.
|
# Actually setup.sh has a dialog UI. Let's just run it or provide a seeded config.
|
||||||
# If setup.sh is strictly interactive, we might need to bypass it or provide answers.
|
# If setup.sh is strictly interactive, we might need to bypass it or provide answers.
|
||||||
# Let's try running docker-compose up directly if setup.sh is too interactive, but the user explicitly said "Make sure setup.sh like your change, then do a full release. Make sure each repo has a current change log, is pushed and and merged." and "Automated testing in theta-env to test integration between all the include projects".
|
# Let's try running docker-compose up directly if setup.sh is too interactive, but the user explicitly said "Make sure setup.sh like your change, then do a full release. Make sure each repo has a current change log, is pushed and and merged." and "Automated testing in theta-suite to test integration between all the include projects".
|
||||||
|
|
||||||
# Wait, setup.sh has no silent mode out of the box unless we provide answers.
|
# Wait, setup.sh has no silent mode out of the box unless we provide answers.
|
||||||
echo "=> Initializing OpenBao manually for tests (simulating setup.sh)"
|
echo "=> Initializing OpenBao manually for tests (simulating setup.sh)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||