Compare commits
326 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7efd3fd6bd | |||
| 9f285c960b | |||
| d75daf81b7 | |||
| 6861a113d2 | |||
| 4542c055bb | |||
| bd2205f1a9 | |||
| d5e0d61546 | |||
| 0dfb69cc9a | |||
| dae0361e82 | |||
| eef7852b69 | |||
| 0ac0c045ec | |||
| dfb819a715 | |||
| d486fb946b | |||
| 39db290265 | |||
| 964ca6dd02 | |||
| 4e388a411e | |||
| e9310a1e5c | |||
| f70419bbc4 | |||
| 358231532f | |||
| d8bd338814 | |||
| 0a3bdbef06 | |||
| 611f1a3318 | |||
| e313697bfd | |||
| 2c3ec4e967 | |||
| b6a82d58d5 | |||
| 18da6582ed | |||
| b1739ec965 | |||
| 7bc6f47070 | |||
| a0964ce350 | |||
| 0d3ee3e2ee | |||
| b95cb08c41 | |||
| a50d1ef3c8 | |||
| daefe54ff7 | |||
| dc3d760d2b | |||
| 9c604f0258 | |||
| d27763e556 | |||
| e5167729a8 | |||
| e915a17cbd | |||
| e4d5e8d75c | |||
| 39a9dc0282 | |||
| 9d266d2e4c | |||
| 3d18c3f0cb | |||
| 214b3a7f5a | |||
| 08dd234710 | |||
| c96a4b6652 | |||
| 0915043d6d | |||
| ddf123d03c | |||
| 9004463311 | |||
| 96410fd8c4 | |||
| f94bb3c14c | |||
| 37e5ad0494 | |||
| 52b85c0d36 | |||
| f98615aae2 | |||
| 72040b1851 | |||
| 93a022bd66 | |||
| c30975329c | |||
| 5c0018f24a | |||
| 6e172bd528 | |||
| 3c98cd4596 | |||
| d3fab004e9 | |||
| e8824cb28d | |||
| 98ed99a7e9 | |||
| bb744adb5e | |||
| 420a9c9f8a | |||
| d442f1e3f9 | |||
| ebd7e9e434 | |||
| 6ce7e67665 | |||
| 583b822e4a | |||
| 9025feef1b | |||
| 620f401091 | |||
| fc9952add3 | |||
| 8a0b796f81 | |||
| 652d3f7d76 | |||
| a442dc9921 | |||
| 5b1302bc6f | |||
| 10e5193077 | |||
| 15d9ce1078 | |||
| 181ca8c9cb | |||
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 | |||
| 03605267bc | |||
| 7e9a271090 | |||
| b28a18064a | |||
| 87339da1b2 | |||
| 49100c9b68 | |||
| e8d04203c3 | |||
| 8db00f0ed6 | |||
| 8a9de94d24 | |||
| 512a28d1f5 | |||
| 398b64f5e3 | |||
| 8d6c7dffd0 | |||
| 50d093f28b | |||
| f00d311029 | |||
| 88b2255d5a | |||
| b0819e81e6 | |||
| d41915f955 | |||
| be8ccf66e9 | |||
| 58597ac8fd | |||
| d02ba32925 | |||
| 6d9c2f05ba | |||
| bbcc235b68 | |||
| 93c47751db | |||
| 9a438bd30e | |||
| c618e75a22 | |||
| 69434d06ec | |||
| dd24257640 | |||
| b06aeca363 | |||
| ccf3122668 | |||
| 5aad6c13bf | |||
| b46b3bed80 | |||
| 948fef4adc | |||
| 2612b0e3ab | |||
| bf471c2e19 | |||
| d802c399a3 | |||
| d8242b1d53 | |||
| 0c5159c49b | |||
| 70b76c6ed5 | |||
| 8143ef8ca8 | |||
| 2b8b7a96e0 | |||
| 7a364bfb8e | |||
| b7aac2d2ba | |||
| 4945dec9c2 | |||
| 59d68c0269 | |||
| ef2207ed72 | |||
| 7782cf8973 | |||
| 80317d1b7e | |||
| ded6a1b0d5 | |||
| a6c24850d4 | |||
| 7da5050ce3 | |||
| 1cb693a1eb | |||
| 5c3a8cefe1 | |||
| 15b3a424bc | |||
| 6cb309b6d9 | |||
| f0ceb750a8 | |||
| 6e95defcf5 | |||
| 92c2e8a03b | |||
| 230e5be2fd | |||
| 0331cb976a | |||
| 90cf65e920 | |||
| 2d202b4979 | |||
| 8f04c20cd7 | |||
| df330c6c0f | |||
| 522093e898 | |||
| 7b84a10420 | |||
| c461723ec7 | |||
| b948cd8625 | |||
| 36aa114d7c | |||
| fbce59b1be | |||
| 554a0999ab | |||
| 3ca221d64d | |||
| 75b133f610 | |||
| f1d52601de | |||
| ecd21c4984 | |||
| 5ba2ace835 | |||
| 25b0d57a97 | |||
| 320e7594e4 | |||
| cec0d92c25 | |||
| 21a56dce50 | |||
| ebb5b2c2a7 | |||
| c8c4cad46d | |||
| 59d4b65195 | |||
| 74746e409b | |||
| 70aed035a5 | |||
| 0264a62b22 | |||
| c212537163 | |||
| 391ad12afc | |||
| 622317b6da | |||
| aa17981c15 | |||
| 99fc0d2819 | |||
| 276629a587 | |||
| a26d54ec6f | |||
| 011d4b2975 | |||
| ecc9b62842 | |||
| e74c5cf11d | |||
| 4a592f9795 | |||
| aa2592ea4e | |||
| 9cf0ce34ca | |||
| 3b6d1ceda9 | |||
| e9b808d1c2 | |||
| 6c71c91ff6 | |||
| ac25084113 | |||
| a788a99e56 | |||
| bcd160cca2 | |||
| 724f5d8496 | |||
| 8fc7dd11f5 | |||
| e91ed6f1f7 | |||
| 874f7db037 | |||
| 013c21d4f0 | |||
| 42a61f8868 | |||
| b54da5c64c | |||
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b | |||
| 1b0418e42e | |||
| 4e3aa082d3 | |||
| 6cb8b259e2 | |||
| 2532c492f1 | |||
| fdc045e166 | |||
| 0c2f38f0fe | |||
| fcba782ac7 | |||
| 6162c6d8a1 | |||
| 3be8c7fde2 | |||
| 3852e9ba62 | |||
| 7f2c71299f | |||
| 18119d54aa | |||
| 487e38f1a4 | |||
| 2e011dd383 | |||
| 3c12ebba16 | |||
| ffb2e99199 | |||
| 9d5f106863 | |||
| 7f00d4c845 | |||
| 1d1d29d287 | |||
| 5665504bc1 | |||
| 2ac1c30112 | |||
| 04c18eaf30 | |||
| 6835074b8b | |||
| 59ae30897b | |||
| 94a7e07410 | |||
| a5de279bb4 | |||
| d8b6f6e7a3 | |||
| 208762f0d1 | |||
| b076498219 | |||
| fc0d9104d0 | |||
| 82da47cef7 | |||
| 39779f51dc | |||
| d9a3cb6044 | |||
| 0ee6825a01 | |||
| 14b6ed5ae0 | |||
| fd98854628 | |||
| 3babf18fe4 | |||
| d78f1dfabf | |||
| c3c206b830 | |||
| 0a21dce0d7 | |||
| 5940880d9b | |||
| 17fcf2fed0 | |||
| ec76054e41 | |||
| 5c0fc4f016 | |||
| 43dae2a3eb | |||
| 20c0a48199 | |||
| 12da7140c2 | |||
| dfd5f46095 | |||
| 5dcc75195c | |||
| 12a99b550c | |||
| 4ce5a5f492 | |||
| c84141b2f3 | |||
| f76d93d840 | |||
| e910a492ba | |||
| 1ef868e23c | |||
| e11e39c23a | |||
| b91089ad4c | |||
| 95ae50a924 | |||
| 0076784fae | |||
| c4d7a1a8e9 | |||
| a100f755ce | |||
| 81ad538e50 | |||
| 6ce36b4a14 | |||
| cda76d3889 | |||
| 2c11226793 | |||
| 80d88b083c | |||
| b4fa824609 | |||
| 5a8030fd7d | |||
| cf80c966eb | |||
| 07819a6254 | |||
| 1b3e842006 | |||
| efe3e514b0 | |||
| 37f2ece172 | |||
| b77704089b | |||
| 114d8c86ca | |||
| 3e87ad86ab | |||
| c5a2c0a71d | |||
| fe23d231be | |||
| 3a612dbed7 | |||
| f154bb8db0 | |||
| 9fc5abda2a | |||
| a6ee985de4 | |||
| 47a9f6c3ec | |||
| f6552cb741 | |||
| 4e7e29b35f | |||
| 4e5a2aa4f9 | |||
| 51784f2f27 | |||
| 4c59b1fabb | |||
| 099638057e | |||
| 077c41844d | |||
| 96adf60cf7 | |||
| 82f703f560 | |||
| 65b107d8ff | |||
| 5d7c0bd594 | |||
| 3ad817767a | |||
| cdc5d1528c | |||
| 5fc65d6fb3 | |||
| ea65a85aa9 | |||
| f8cf68b85f | |||
| cedef0ed09 | |||
| 0e31320964 | |||
| f12ce8c600 | |||
| b358e3b0b0 | |||
| 88387f3117 | |||
| e2b4ffabb7 | |||
| a466128c21 | |||
| b03c0af09d | |||
| be41597502 | |||
| 21f2cda2ee | |||
| 976c3439fc | |||
| 81e36c9928 | |||
| bc5bca2e28 | |||
| 4c4fc34dcf | |||
| 3e67c23008 | |||
| b657c4034b | |||
| f323a45fef | |||
| ff10a23e78 | |||
| c2851ea537 | |||
| 98d767a201 | |||
| 955189d08a | |||
| 65e43a5677 | |||
| f3885bb3df | |||
| c3e086fc7b | |||
| aaa538c7f9 |
@@ -9,13 +9,23 @@
|
|||||||
.claude
|
.claude
|
||||||
*.md
|
*.md
|
||||||
# README.md and tos.md are both read at runtime (tos.md is loaded by
|
# README.md and tos.md are both read at runtime (tos.md is loaded by
|
||||||
# routes/index.js at boot), so they must stay in the build context.
|
# routes/index.js at boot). DEPLOYMENT.md/API.md/directory_spec.md/docs/*.md
|
||||||
|
# are read at runtime too, by routes/docs.js -- all must stay in the build
|
||||||
|
# context.
|
||||||
!README.md
|
!README.md
|
||||||
!tos.md
|
!tos.md
|
||||||
|
!CHANGELOG.md
|
||||||
|
!DEPLOYMENT.md
|
||||||
|
!API.md
|
||||||
|
!directory_spec.md
|
||||||
|
!docs/**/*.md
|
||||||
|
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
|
||||||
|
# in Dockerfile.openldap needs these present in the build context.
|
||||||
|
!docs/images/**
|
||||||
|
|
||||||
# Tests
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
nodejs/tests/
|
# nodejs/tests/
|
||||||
nodejs/*.test.js
|
# nodejs/*.test.js
|
||||||
|
|
||||||
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
|
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
|
||||||
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
|
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# GitGuardian configuration (ggshield / GitGuardian GH checks).
|
||||||
|
#
|
||||||
|
# The generic-password detector false-positives on LDAP admin bind credentials
|
||||||
|
# being READ from runtime config (sso-secrets.js / /config/site.json) — e.g.
|
||||||
|
# `const x = conf.ldap && conf.ldap.bindPassword` in the multi-site join flow.
|
||||||
|
# That is the correct pattern (never a hardcoded secret); ignore the variable
|
||||||
|
# reference, not the actual value.
|
||||||
|
version: 2
|
||||||
|
ignore:
|
||||||
|
- name: generic-password
|
||||||
|
match: |
|
||||||
|
conf\.ldap\s*&&\s*conf\.ldap\.bindPassword
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build OpenLDAP Base Image
|
||||||
|
|
||||||
|
# Publishes ghcr.io/theta42/openldap-nestgroup, the prebuilt slapd-with-
|
||||||
|
# nestgroup image Dockerfile.openldap's `ldapbuild` stage pulls FROM instead
|
||||||
|
# of compiling from source on every build (see Dockerfile.openldap-builder
|
||||||
|
# for why, and the ~5 minute + git.openldap.org-dependent cost it replaces).
|
||||||
|
#
|
||||||
|
# Runs only when the builder Dockerfile changes -- bumping OPENLDAP_COMMIT in
|
||||||
|
# it is the only reason this image should ever need rebuilding -- or on
|
||||||
|
# manual dispatch.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile.openldap-builder'
|
||||||
|
- '.github/workflows/build-openldap-image.yml'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Single source of truth for the tag: the ARG default in the Dockerfile
|
||||||
|
# itself, not a value duplicated into this workflow.
|
||||||
|
- name: Resolve pinned OpenLDAP commit
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
commit=$(grep -oP '^ARG OPENLDAP_COMMIT=\K[0-9a-f]+' Dockerfile.openldap-builder)
|
||||||
|
if [ -z "$commit" ]; then
|
||||||
|
echo "::error::Could not resolve OPENLDAP_COMMIT from Dockerfile.openldap-builder"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "commit=$commit" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- 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
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.openldap-builder
|
||||||
|
build-args: |
|
||||||
|
OPENLDAP_COMMIT=${{ steps.commit.outputs.commit }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/theta42/openldap-nestgroup:${{ steps.commit.outputs.commit }}
|
||||||
|
ghcr.io/theta42/openldap-nestgroup:latest
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
name: Pull Request Tests
|
||||||
|
|
||||||
|
# Run tests on pull requests to master and when pushing to PRs
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18.x, 20.x, 22.x]
|
||||||
|
|
||||||
|
# A dedicated, GHA-managed Redis -- NOT the bundled image's own Redis,
|
||||||
|
# which only binds to loopback *inside* its container (redis-server's
|
||||||
|
# default with no --bind override), so Docker's -p port-forward can
|
||||||
|
# never actually reach it from the runner. This service container binds
|
||||||
|
# correctly and is reachable at localhost:6379, matching model-redis's
|
||||||
|
# createClient({}) default when conf.redis has no explicit host/port.
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The test suite (require('../app')) also needs a real LDAP directory
|
||||||
|
# seeded with the schema/groups the app expects -- the bundled image
|
||||||
|
# already does exactly that (docker-entrypoint.sh), so build and run
|
||||||
|
# it here rather than reimplementing LDAP setup as a separate
|
||||||
|
# CI-only script. Its own bundled Redis is unused (see services above).
|
||||||
|
- name: Build LDAP test image
|
||||||
|
run: docker build -f Dockerfile.openldap -t sso-test:latest .
|
||||||
|
|
||||||
|
- name: Start LDAP test container
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/sso-test-config
|
||||||
|
cp secrets.js.example /tmp/sso-test-config/sso-secrets.js
|
||||||
|
docker run -d --name sso-test \
|
||||||
|
-p 389:389 -p 3001:3001 \
|
||||||
|
-v /tmp/sso-test-config:/config:ro \
|
||||||
|
sso-test:latest
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
status=$(docker inspect --format='{{.State.Health.Status}}' sso-test 2>/dev/null || echo starting)
|
||||||
|
[ "$status" = "healthy" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker inspect --format='{{.State.Health.Status}}' sso-test
|
||||||
|
|
||||||
|
# tests/setup.js logs in as uid 'test'; several suites (group/otp/
|
||||||
|
# impersonate/cache) assume a second, non-admin user 'wmantly' already
|
||||||
|
# exists (documented in those test files: "wmantly is always present
|
||||||
|
# in the test LDAP"). Seed both here so CI matches that assumption.
|
||||||
|
- name: Seed test fixtures
|
||||||
|
run: |
|
||||||
|
HASH_TEST=$(timeout 20 docker exec sso-test node -e "console.log(require('/app/models/user_ldap.js').hashPasswordSSHA512('MyTestPassword!2'))" | tail -1)
|
||||||
|
HASH_WMANTLY=$(timeout 20 docker exec sso-test node -e "console.log(require('/app/models/user_ldap.js').hashPasswordSSHA512('WmantlyPass!2'))" | tail -1)
|
||||||
|
cat > /tmp/seed.ldif <<EOF
|
||||||
|
dn: cn=test,ou=people,dc=example,dc=com
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: posixAccount
|
||||||
|
objectClass: theta42Person
|
||||||
|
cn: test
|
||||||
|
sn: Test
|
||||||
|
mail: test@example.com
|
||||||
|
uid: test
|
||||||
|
uidNumber: 10000
|
||||||
|
gidNumber: 10000
|
||||||
|
homeDirectory: /home/test
|
||||||
|
userPassword: ${HASH_TEST}
|
||||||
|
dateOfBirth: 2000-01-01
|
||||||
|
|
||||||
|
dn: cn=app_sso_admin,ou=groups,dc=example,dc=com
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=test,ou=people,dc=example,dc=com
|
||||||
|
|
||||||
|
dn: cn=app_sso_oauth_admin,ou=groups,dc=example,dc=com
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=test,ou=people,dc=example,dc=com
|
||||||
|
|
||||||
|
dn: cn=app_sso_invite,ou=groups,dc=example,dc=com
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=test,ou=people,dc=example,dc=com
|
||||||
|
|
||||||
|
dn: cn=wmantly,ou=people,dc=example,dc=com
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: posixAccount
|
||||||
|
objectClass: theta42Person
|
||||||
|
cn: wmantly
|
||||||
|
sn: Mantly
|
||||||
|
mail: wmantly@example.com
|
||||||
|
uid: wmantly
|
||||||
|
uidNumber: 10001
|
||||||
|
gidNumber: 10001
|
||||||
|
homeDirectory: /home/wmantly
|
||||||
|
userPassword: ${HASH_WMANTLY}
|
||||||
|
dateOfBirth: 2000-01-01
|
||||||
|
EOF
|
||||||
|
docker cp /tmp/seed.ldif sso-test:/tmp/seed.ldif
|
||||||
|
docker exec sso-test ldapmodify -x -D "cn=admin,dc=example,dc=com" -w 'your-ldap-password' -a -f /tmp/seed.ldif
|
||||||
|
|
||||||
|
- name: Setup Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: nodejs/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: ./nodejs
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: ./nodejs
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
# conf/base.js's ldap.* defaults already match secrets.js.example's
|
||||||
|
# directory layout (dc=example,dc=com) -- only the admin password
|
||||||
|
# (normally supplied via a gitignored secrets.js) needs setting.
|
||||||
|
app_ldap__bindPassword: your-ldap-password
|
||||||
|
# routes/oauth.js now refuses to start without a real jwtSecret.
|
||||||
|
# This is a non-secret test value; the container under test uses
|
||||||
|
# secrets.js.example's jwtSecret independently.
|
||||||
|
app_oauth__jwtSecret: ci-test-jwt-secret-do-not-use-in-production
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
test-summary:
|
||||||
|
name: Test Summary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check test results
|
||||||
|
run: |
|
||||||
|
if [ "${{ needs.test.result }}" != "success" ]; then
|
||||||
|
echo "Tests failed. PR cannot be merged."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All tests passed successfully!"
|
||||||
@@ -86,6 +86,17 @@ ops/cookbooks/vendor
|
|||||||
secrets.json
|
secrets.json
|
||||||
secrets.js
|
secrets.js
|
||||||
|
|
||||||
|
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
|
||||||
|
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
|
||||||
|
# they must never be committed. The empty *.example templates ARE tracked.
|
||||||
|
config/*-secrets.js
|
||||||
|
|
||||||
|
# Default sqlite ORM storage (nodejs/models/index.js falls back to this path
|
||||||
|
# when no external DB is configured via conf.orm) -- live runtime data, not a
|
||||||
|
# fixture. Was committed by mistake across many prior releases. NB: this is
|
||||||
|
# nodejs/config/, distinct from the root ./config/ secrets dir above.
|
||||||
|
nodejs/config/*.sqlite
|
||||||
|
|
||||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||||
docs/_site
|
docs/_site
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# SSO Manager API Documentation
|
# SSO Manager API Documentation
|
||||||
|
|
||||||
|
> Looking for a plainer explanation of what API tokens are and when you'd
|
||||||
|
> want one, instead of a full endpoint reference? See
|
||||||
|
> [API Tokens](/docs/api-tokens) (in-app) or
|
||||||
|
> [concepts-api-tokens.md](docs/concepts-api-tokens.md) (repo).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
|
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
|
||||||
@@ -698,6 +703,10 @@ The authenticated user is automatically set as the group owner.
|
|||||||
{ "results": true, "message": "Added user uid to group group." }
|
{ "results": true, "message": "Added user uid to group group." }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Returns `409` if the user is already a member — common in practice, since
|
||||||
|
`groupOfNames` requires at least one member and so seeds whoever created the
|
||||||
|
group into it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Remove User from Group
|
### Remove User from Group
|
||||||
@@ -711,6 +720,66 @@ The authenticated user is automatically set as the group owner.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Nest a Group Inside Another
|
||||||
|
|
||||||
|
**`PUT /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||||
|
|
||||||
|
Makes `:child` a member of `:group`, so everyone in `:child` is a member of
|
||||||
|
`:group` at any depth.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "results": { "cn": "group", "member": ["..."] }, "message": "Nested child inside group." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|--------|------|
|
||||||
|
| `400` | `:group` and `:child` are the same group |
|
||||||
|
| `409` | already nested, or the nesting would create a loop (`:child` already contains `:group`, directly or transitively) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Un-nest a Group
|
||||||
|
|
||||||
|
**`DELETE /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "results": { "cn": "group", "member": ["..."] }, "message": "Removed child from group." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|--------|------|
|
||||||
|
| `409` | `:child` is the only member — `groupOfNames` requires at least one |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Effective Membership
|
||||||
|
|
||||||
|
**`GET /api/group/:group/effective`** — Any authenticated user
|
||||||
|
|
||||||
|
Who a group actually grants. `direct` is users listed on the group itself
|
||||||
|
(never groups); `nestedGroups` is what is nested into it; `effective` is every
|
||||||
|
user reachable through the whole chain.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": {
|
||||||
|
"cn": "app_gitea_access",
|
||||||
|
"direct": ["cn=alice,ou=people,dc=example,dc=com"],
|
||||||
|
"nestedGroups": [{ "cn": "developers", "dn": "cn=developers,ou=groups,dc=example,dc=com" }],
|
||||||
|
"effective": ["cn=alice,ou=people,dc=example,dc=com", "cn=bob,ou=people,dc=example,dc=com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Delete Group
|
### Delete Group
|
||||||
|
|
||||||
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
||||||
@@ -1130,6 +1199,215 @@ Configurable per-client via `token_lifetime`. Global defaults (in seconds):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Plugin Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/plugins`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `app_super_admin` membership. Secret field values are always returned masked (`********`); they are stored in OpenBao at `secret/plugins/<instance-id>/conf`, never in the database row. See [Plugins](docs/plugins.html).
|
||||||
|
|
||||||
|
### List Plugin Types
|
||||||
|
|
||||||
|
**`GET /api/plugins/types`**
|
||||||
|
|
||||||
|
Returns the installed plugin types and their `configSchema` (used to build the create-instance form).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"type": "proxmox",
|
||||||
|
"category": "discovery",
|
||||||
|
"name": "Proxmox VE",
|
||||||
|
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
|
||||||
|
"configSchema": [
|
||||||
|
{ "key": "url", "label": "API URL", "type": "url", "required": true },
|
||||||
|
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
|
||||||
|
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Plugin Instances
|
||||||
|
|
||||||
|
**`GET /api/plugins/`**
|
||||||
|
|
||||||
|
**Response:** `{ "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get One Instance
|
||||||
|
|
||||||
|
**`GET /api/plugins/:id`** — same shape as a list entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Create Instance
|
||||||
|
|
||||||
|
**`POST /api/plugins/`**
|
||||||
|
|
||||||
|
`config` is a flat object of **all** field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. `slug` is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pluginType": "proxmox",
|
||||||
|
"name": "Proxmox — Home Lab",
|
||||||
|
"slug": "proxmox-homelab",
|
||||||
|
"cron": "0 * * * *",
|
||||||
|
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; `400` with an OpenBao hint if writing the secret fails (re-run `./setup.sh` with theta-suite ≥ v1.30.1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Instance
|
||||||
|
|
||||||
|
**`PUT /api/plugins/:id`** — update `name`, `cron`, `enabled`, and non-secret `config`. Secret fields are changed via `PUT /:id/secrets`. Re-schedules if `cron` or `enabled` changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Secrets
|
||||||
|
|
||||||
|
**`PUT /api/plugins/:id/secrets`** — body is a flat object of secret field values. Blank/`********` values are ignored (kept as-is).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test Instance
|
||||||
|
|
||||||
|
**`POST /api/plugins/:id/test`** — runs the plugin's `validate`. Returns `{ "ok": true }` or `400 { "ok": false, "error": "..." }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Load / Unload / Run Now
|
||||||
|
|
||||||
|
- **`POST /api/plugins/:id/load`** — enable + schedule + run now.
|
||||||
|
- **`POST /api/plugins/:id/unload`** — unschedule + disable.
|
||||||
|
- **`POST /api/plugins/:id/run`** — enqueue one immediate run (regardless of enabled).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Last Run Status
|
||||||
|
|
||||||
|
**`GET /api/plugins/:id/runs`** → `{ "results": { "lastRunAt", "lastStatus", "lastError" } }` (`lastStatus` is `ok` | `error` | `running`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Delete Instance
|
||||||
|
|
||||||
|
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
|
||||||
|
|
||||||
|
## Configuration Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/conf`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
|
||||||
|
|
||||||
|
### Get Configuration
|
||||||
|
|
||||||
|
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||||
|
"discovery": { },
|
||||||
|
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Configuration
|
||||||
|
|
||||||
|
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||||
|
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ "success": true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subtype Driver Operations Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/directory-admin/resources`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission.
|
||||||
|
|
||||||
|
### Get Subtype Driver Metrics
|
||||||
|
|
||||||
|
**`GET /api/directory-admin/resources/:id/driver-metrics`**
|
||||||
|
|
||||||
|
Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"metrics": {
|
||||||
|
"status": "online",
|
||||||
|
"driver": "database",
|
||||||
|
"subType": "redis",
|
||||||
|
"redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Execute Subtype Driver Action
|
||||||
|
|
||||||
|
**`POST /api/directory-admin/resources/:id/driver-action`**
|
||||||
|
|
||||||
|
Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "restart",
|
||||||
|
"params": { "serviceName": "emby-server" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"result": { "status": "ok", "driver": "docker_socket", "action": "restart" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Subtype Driver Logs
|
||||||
|
|
||||||
|
**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`**
|
||||||
|
|
||||||
|
Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"logs": "[docker logs --tail 100 emby-server]\nContainer initialized..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return errors in this format:
|
All endpoints return errors in this format:
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
|||||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
||||||
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
|
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
|
||||||
|
|
||||||
### API tokens (personal access tokens)
|
### API tokens (personal access tokens)
|
||||||
|
|
||||||
@@ -176,6 +176,8 @@ docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
|
|||||||
| `PORT` | `3001` | host port mapped to the UI |
|
| `PORT` | `3001` | host port mapped to the UI |
|
||||||
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
||||||
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
|
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
|
||||||
|
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
|
||||||
|
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
|
||||||
|
|
||||||
Any `app_*` var may also be set directly to override any config value (see the
|
Any `app_*` var may also be set directly to override any config value (see the
|
||||||
table at the top).
|
table at the top).
|
||||||
@@ -188,6 +190,12 @@ valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on
|
|||||||
`ldap-certs` volume so it persists across container recreation — clients don't need
|
`ldap-certs` volume so it persists across container recreation — clients don't need
|
||||||
to re-trust on every rebuild.
|
to re-trust on every rebuild.
|
||||||
|
|
||||||
|
The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
|
||||||
|
To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
|
||||||
|
or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
|
||||||
|
secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
|
||||||
|
recommended network layouts and how to match the cert SAN to the hostname.
|
||||||
|
|
||||||
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
|
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
|
||||||
out of the container and add it to the client's trusted CA store, or set
|
out of the container and add it to the client's trusted CA store, or set
|
||||||
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
|
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
|
||||||
@@ -328,14 +336,32 @@ OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
|
|||||||
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
||||||
OAuth clients live in SSO Redis and are preserved by the volume.
|
OAuth clients live in SSO Redis and are preserved by the volume.
|
||||||
|
|
||||||
|
> **Note — the bundled slapd is built from source.** The all-in-one image
|
||||||
|
> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup`
|
||||||
|
> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release
|
||||||
|
> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is
|
||||||
|
> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x
|
||||||
|
> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x
|
||||||
|
> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same
|
||||||
|
> shape as "Restore — LDAP only" above. Verify after a rebuild:
|
||||||
|
> `docker compose logs sso-manager | grep nestgroup` should report the overlay
|
||||||
|
> as available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Method 2: Bare metal (Debian/Ubuntu)
|
## Method 2: Bare metal (Debian/Ubuntu)
|
||||||
|
|
||||||
`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and
|
`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
|
||||||
configures OpenLDAP (modules + overlays + custom schema + directory tree +
|
force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
|
||||||
required groups), deploys the app to `/opt/sso-manager`, and creates a systemd
|
config from the repo. Re-run it to update — it prints the version you're
|
||||||
unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based).
|
updating from and to (or "Already up to date" if there's nothing new).
|
||||||
|
|
||||||
|
On the **first run only** it also installs and configures OpenLDAP (modules +
|
||||||
|
overlays + custom schema + directory tree + required groups — see
|
||||||
|
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
|
||||||
|
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
|
||||||
|
file exists it's never touched again, and LDAP is never re-bootstrapped —
|
||||||
|
edit the file and restart the service to change anything.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
@@ -346,47 +372,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based
|
|||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh \
|
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
|
||||||
-p 'your-ldap-password' \
|
|
||||||
-b 'dc=yourdomain,dc=com' \
|
|
||||||
-n 'Your Org' \
|
|
||||||
-o 3001
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Env var | Description |
|
or, if you already have the repo checked out:
|
||||||
|------|---------|-------------|
|
|
||||||
| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) |
|
```bash
|
||||||
| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) |
|
sudo ./install.sh
|
||||||
| `-n, --org-name` | `ORG_NAME` | Org name (default `SSO Manager`) |
|
```
|
||||||
| `-o, --port` | `PORT` | HTTP port (default `3001`) |
|
|
||||||
| `-j, --jwt-secret` | `JWT_SECRET` | JWT secret (default auto-generated) |
|
| Env var | Description |
|
||||||
| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` |
|
|---------|-------------|
|
||||||
| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) |
|
| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
|
||||||
| `--skip-app` | `SKIP_APP` | LDAP setup only |
|
| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
|
||||||
| `--dry-run` | `DRY_RUN` | Show actions without making changes |
|
| `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
|
||||||
|
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
|
||||||
|
| `PORT` | HTTP port (default `3001`) — first run only |
|
||||||
|
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
|
||||||
|
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
|
||||||
|
|
||||||
### Post-install
|
### Post-install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl enable --now sso-manager
|
sudo systemctl status sso-manager
|
||||||
journalctl -fu sso-manager
|
journalctl -fu sso-manager
|
||||||
curl http://localhost:3001/health # -> {"status":"ok"}
|
curl http://localhost:3001/health # -> {"status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### What `install.sh` does
|
### What `install.sh` does
|
||||||
|
|
||||||
1. Installs Node.js 20.x (NodeSource).
|
1. Installs Node.js 22.x (NodeSource) and Redis.
|
||||||
2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint`
|
2. Clones/updates the repo at `/opt/theta42/sso-manager`.
|
||||||
modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes;
|
3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
|
||||||
`ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups.
|
`memberof`, `refint` modules + overlays; the custom `theta42Person` schema
|
||||||
3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`.
|
(`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
|
||||||
4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults).
|
`pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
|
||||||
5. Installs `sso-manager.service` (systemd), enabled on boot.
|
4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
|
||||||
|
runs `npm ci --omit=dev`.
|
||||||
|
5. Enables and (re)starts the service.
|
||||||
|
|
||||||
> For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the
|
> For an existing LDAP server, run with `SKIP_LDAP=true` and write
|
||||||
> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use
|
> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
|
||||||
> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer
|
> starting the service. To (re)configure overlays on an already-installed
|
||||||
> `ops/ldap-setup.sh` (idempotent, auto-detects the user database).
|
> slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user
|
||||||
|
> database).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -465,8 +494,8 @@ netstat -tlnp | grep 389
|
|||||||
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
||||||
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
||||||
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
|
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||||
use `ldaps://…:636` or StartTLS.
|
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
||||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
|
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
|
||||||
# the host where the submodule resolves correctly.
|
# the host where the submodule resolves correctly.
|
||||||
ARG GIT_COMMIT=""
|
ARG GIT_COMMIT=""
|
||||||
|
# Pinned OpenLDAP commit this build expects -- must match the tag of the
|
||||||
|
# published builder image below. Bumping it is a two-step change: rebuild +
|
||||||
|
# push ghcr.io/theta42/openldap-nestgroup:<new commit> from
|
||||||
|
# Dockerfile.openldap-builder (see that file), then update this default (or
|
||||||
|
# pass --build-arg OPENLDAP_COMMIT=<new commit> here).
|
||||||
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||||
FROM node:20-alpine AS gitinfo
|
FROM node:20-alpine AS gitinfo
|
||||||
ARG GIT_COMMIT
|
ARG GIT_COMMIT
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
@@ -33,37 +39,69 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
|||||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── OpenLDAP, prebuilt ────────────────────────────────────────────────────────
|
||||||
|
# We run slapd from OpenLDAP master rather than Alpine's packaged release, for
|
||||||
|
# exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||||
|
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||||
|
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||||
|
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||||
|
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
|
||||||
|
# where any consumer that forgets silently under-grants access.
|
||||||
|
#
|
||||||
|
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
|
||||||
|
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||||
|
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||||
|
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||||
|
#
|
||||||
|
# The from-source compile (~5 min, and a dependency on git.openldap.org being
|
||||||
|
# reachable) used to happen right here, on every build of this Dockerfile --
|
||||||
|
# including 3x per CI run's test matrix. It's now built once, tagged by the
|
||||||
|
# pinned commit above, in Dockerfile.openldap-builder -- see that file for the
|
||||||
|
# actual compile steps and the TODO on dropping from-source entirely once
|
||||||
|
# nestgroup ships in a release.
|
||||||
|
FROM ghcr.io/theta42/openldap-nestgroup:${OPENLDAP_COMMIT} AS ldapbuild
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenLDAP and required packages.
|
# Runtime libraries the from-source slapd links against, plus the app's own
|
||||||
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
|
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
||||||
# "openldap-overlays" package. We install exactly the backends/overlays/modules
|
# libltdl (module loading -- slapd is useless without it, since every overlay
|
||||||
# the app depends on:
|
# is a loadable module) and libuuid are pulled in by the source build but are
|
||||||
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
|
# NOT dependencies of anything else here, so they must be named explicitly;
|
||||||
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
|
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
||||||
# openldap-overlay-memberof : reverse group membership
|
# symbol not found", not at build time.
|
||||||
# openldap-overlay-refint : referential integrity on group members
|
|
||||||
# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing)
|
|
||||||
# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy
|
|
||||||
# schema is built into ppolicy.so and registered when the module loads, so
|
|
||||||
# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include).
|
|
||||||
# openssl : used by docker-entrypoint.sh to generate a JWT secret
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openldap \
|
openssl \
|
||||||
openldap-clients \
|
libsasl \
|
||||||
openldap-back-mdb \
|
libltdl \
|
||||||
openldap-overlay-ppolicy \
|
libuuid \
|
||||||
openldap-overlay-memberof \
|
|
||||||
openldap-overlay-refint \
|
|
||||||
openldap-passwd-sha2 \
|
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
openssl \
|
|
||||||
redis \
|
redis \
|
||||||
|
nmap \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# The openldap package already creates the `ldap` user/group, which slapd runs
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
|
# Which upstream commit this slapd was built from — so a running container can
|
||||||
|
# answer "what am I actually running" without rebuilding.
|
||||||
|
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
|
||||||
|
|
||||||
|
# The Alpine openldap package used to create these; nothing does now, and
|
||||||
|
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
|
||||||
|
RUN addgroup -S ldap 2>/dev/null || true \
|
||||||
|
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
|
||||||
|
|
||||||
|
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
|
||||||
|
# and probes a list of candidate module directories, so putting the from-source
|
||||||
|
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
|
||||||
|
# into the conventional location because the entrypoint's slapd.conf includes
|
||||||
|
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
|
||||||
|
# openssh-lpk) are copied there too.
|
||||||
|
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
|
||||||
|
RUN mkdir -p /etc/openldap/schema \
|
||||||
|
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
|
||||||
|
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
|
||||||
|
done
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -81,6 +119,7 @@ COPY nodejs/app.js ./
|
|||||||
COPY nodejs/bin ./bin
|
COPY nodejs/bin ./bin
|
||||||
COPY nodejs/conf ./conf
|
COPY nodejs/conf ./conf
|
||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
|
COPY nodejs/drivers ./drivers
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
COPY nodejs/models ./models
|
||||||
COPY nodejs/routes ./routes
|
COPY nodejs/routes ./routes
|
||||||
@@ -88,6 +127,7 @@ COPY nodejs/services ./services
|
|||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
|
|
||||||
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
||||||
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
||||||
@@ -95,6 +135,18 @@ COPY nodejs/public ./public
|
|||||||
# level above the nodejs/ app dir). Without this the app crashes on startup.
|
# level above the nodejs/ app dir). Without this the app crashes on startup.
|
||||||
COPY tos.md /tos.md
|
COPY tos.md /tos.md
|
||||||
|
|
||||||
|
# Documentation, served in-app at /docs (routes/docs.js) so it's readable
|
||||||
|
# without internet access. Same flattened-path convention as tos.md above.
|
||||||
|
COPY README.md /README.md
|
||||||
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
|
COPY API.md /API.md
|
||||||
|
COPY directory_spec.md /directory_spec.md
|
||||||
|
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
|
||||||
|
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
|
||||||
|
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
|
||||||
|
# fs.readFileSync in routes/docs.js (files missing from the image).
|
||||||
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|
||||||
@@ -120,7 +172,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
|
|||||||
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
||||||
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
||||||
# if you want LAN clients to bind without TLS (not recommended).
|
# if you want LAN clients to bind without TLS (not recommended).
|
||||||
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
|
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
|
||||||
|
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
|
||||||
EXPOSE 3001 389 636
|
EXPOSE 3001 389 636
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# OpenLDAP-with-nestgroup builder, published to
|
||||||
|
# ghcr.io/theta42/openldap-nestgroup:<OPENLDAP_COMMIT short hash>.
|
||||||
|
#
|
||||||
|
# Extracted out of Dockerfile.openldap's `ldapbuild` stage so the ~5 minute
|
||||||
|
# from-source compile (which also depends on git.openldap.org being up)
|
||||||
|
# happens once, here, instead of on every `docker build` of the app image --
|
||||||
|
# including every CI run's 3-way test matrix. Dockerfile.openldap's ldapbuild
|
||||||
|
# stage becomes `FROM ghcr.io/theta42/openldap-nestgroup:<commit>` and the
|
||||||
|
# rest of that file (the COPY --from=ldapbuild lines) is unchanged, since
|
||||||
|
# COPY --from also accepts an external image, not just a local stage name.
|
||||||
|
#
|
||||||
|
# Bumping OPENLDAP_COMMIT is a two-step change: update the ARG below, push
|
||||||
|
# (the build-openldap-image workflow rebuilds+republishes the tag on changes
|
||||||
|
# to this file), then update the matching FROM line in Dockerfile.openldap.
|
||||||
|
#
|
||||||
|
# See Dockerfile.openldap's own "OpenLDAP from source" comment for *why*
|
||||||
|
# from-source at all (the nestgroup overlay, ITS#10161) and the LMDB format
|
||||||
|
# note (master's 1.0.0 vs 2.6.x's 0.9.x).
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
# groff is not optional despite producing nothing we ship: the build descends
|
||||||
|
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||||
|
# provides. Without it the whole `make` fails at the man-page stage
|
||||||
|
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base autoconf automake libtool \
|
||||||
|
openssl-dev cyrus-sasl-dev \
|
||||||
|
git make pkgconf util-linux-dev groff
|
||||||
|
|
||||||
|
# Pinned to an exact commit, not a branch tip -- see Dockerfile.openldap for
|
||||||
|
# why (this is the directory server the whole lab authenticates against).
|
||||||
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN git init -q . \
|
||||||
|
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||||
|
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||||
|
&& git checkout -q FETCH_HEAD \
|
||||||
|
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||||
|
|
||||||
|
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||||
|
# `moduleload`s them individually; nestgroup joins that set.
|
||||||
|
RUN ./configure \
|
||||||
|
--prefix=/opt/openldap \
|
||||||
|
--enable-slapd \
|
||||||
|
--enable-modules \
|
||||||
|
--enable-mdb \
|
||||||
|
--enable-memberof=mod \
|
||||||
|
--enable-refint=mod \
|
||||||
|
--enable-ppolicy=mod \
|
||||||
|
--enable-dynlist=mod \
|
||||||
|
--enable-nestgroup=mod \
|
||||||
|
--enable-syncprov=mod \
|
||||||
|
--enable-auditlog=mod \
|
||||||
|
--with-tls=openssl \
|
||||||
|
--with-cyrus-sasl \
|
||||||
|
&& make depend \
|
||||||
|
&& make -j"$(nproc)" \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||||
|
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||||
|
# built separately against the just-built tree -- omitting it would make every
|
||||||
|
# user password unverifiable.
|
||||||
|
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||||
|
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||||
|
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||||
|
|
||||||
|
# Pure artifact holder -- no shell, no package manager, nothing but the
|
||||||
|
# compiled tree. Dockerfile.openldap's COPY --from=ldapbuild only ever reads
|
||||||
|
# files, never RUNs anything in this stage, so scratch is sufficient and
|
||||||
|
# keeps the published image (and every pull of it) as small as possible.
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /opt/openldap /opt/openldap
|
||||||
|
COPY --from=build /opt-openldap-commit.txt /opt-openldap-commit.txt
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Test-runner image for SSO Manager.
|
||||||
|
#
|
||||||
|
# Installs all dependencies (including dev) and bundles the app code plus
|
||||||
|
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
|
||||||
|
# user, then runs whatever command is given (default: npm test).
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
|
||||||
|
RUN apk add --no-cache openldap-clients bash
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy and install dependencies (including devDependencies for jest/supertest)
|
||||||
|
COPY nodejs/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the application source
|
||||||
|
COPY nodejs/app.js ./
|
||||||
|
COPY nodejs/bin ./bin
|
||||||
|
COPY nodejs/conf ./conf
|
||||||
|
COPY nodejs/controller ./controller
|
||||||
|
COPY nodejs/drivers ./drivers
|
||||||
|
COPY nodejs/middleware ./middleware
|
||||||
|
COPY nodejs/models ./models
|
||||||
|
# Without this the discovery/plugin suites cannot even load their subject and
|
||||||
|
# fail as "Cannot find module ../plugins/discovery/..." -- plugin code was
|
||||||
|
# effectively untested in CI.
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
|
COPY nodejs/routes ./routes
|
||||||
|
COPY nodejs/services ./services
|
||||||
|
COPY nodejs/utils ./utils
|
||||||
|
COPY nodejs/views ./views
|
||||||
|
COPY nodejs/public ./public
|
||||||
|
COPY nodejs/tests ./tests
|
||||||
|
|
||||||
|
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
|
||||||
|
RUN mkdir -p /app/config
|
||||||
|
|
||||||
|
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
|
||||||
|
COPY tos.md /tos.md
|
||||||
|
COPY README.md /README.md
|
||||||
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
|
COPY API.md /API.md
|
||||||
|
COPY directory_spec.md /directory_spec.md
|
||||||
|
|
||||||
|
# Seed script and utility
|
||||||
|
COPY test_seed.js ./test_seed.js
|
||||||
|
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
||||||
|
RUN chmod +x /usr/local/bin/seed-test-user
|
||||||
|
# End-to-end LDAP tunnel test client (docker-compose.e2e.yml)
|
||||||
|
COPY test/tunnel_e2e.js ./test/tunnel_e2e.js
|
||||||
|
# End-to-end multi-site join test client (docker-compose.multisite-e2e.yml)
|
||||||
|
COPY test/multisite_join_e2e.js ./test/multisite_join_e2e.js
|
||||||
|
|
||||||
|
# Default command: seed the test user, then run the test suite
|
||||||
|
CMD ["sh", "-c", "seed-test-user && npm test"]
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
# SSO Manager
|
# Theta Directory
|
||||||
|
|
||||||
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
|
A production-grade, self-hosted **OpenID Connect provider**, **Resource Directory & IAM Engine**, and bundled **OpenLDAP directory** with a modern web console — designed for home-labs and enterprise infrastructure that demand total sovereignty over their identity, secrets, and resource catalog.
|
||||||
and a web management UI — for home labs and small businesses that want their own
|
|
||||||
identity provider instead of a hosted one.
|
|
||||||
|
|
||||||
It gives you one place to manage your users and groups, one login (OIDC) that
|
It provides a single source of truth for identity (OIDC + LDAP), host/service directory inventory, access control groups, and secrets management running entirely on your own hardware without third-party cloud lock-in.
|
||||||
your modern apps can use, and one LDAP directory your older or odder apps can
|
|
||||||
bind to directly. Everything runs on your own hardware; there is no
|
|
||||||
phone-home, no hosted control plane, and no per-user pricing.
|
|
||||||
|
|
||||||
> Setting up the whole stack (this SSO + the [theta42/proxy](https://github.com/theta42/proxy)
|
Theta Directory is deployed as part of [Theta Suite](https://github.com/theta42/theta-suite),
|
||||||
> in front of it) with one command? Skip to [theta-env](https://github.com/theta42/theta-env)
|
alongside [Theta Proxy](https://github.com/theta42/proxy) and
|
||||||
> — its `setup.sh` wires the two together and generates the config for you.
|
[Theta Gateway](https://github.com/theta42/jump-host) — it isn't installed or
|
||||||
|
run on its own. `./setup.sh` wires the whole stack together automatically.
|
||||||
|
|
||||||
**Documentation:** [https://theta42.github.io/sso-manager-node/](https://theta42.github.io/sso-manager-node/)
|
**Documentation:** [https://theta42.github.io/theta-suite/sso/](https://theta42.github.io/theta-suite/sso/)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -25,6 +21,14 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [](docs/images/groups.png) | [](docs/images/oauth-clients.png) |
|
| [](docs/images/groups.png) | [](docs/images/oauth-clients.png) |
|
||||||
|
|
||||||
|
| Sites & Replication |
|
||||||
|
| --- |
|
||||||
|
| [](docs/images/sites.png) |
|
||||||
|
|
||||||
|
| Agent Capabilities & Metrics | Agent Install (Join Key) |
|
||||||
|
| --- | --- |
|
||||||
|
| [](docs/images/agent-capabilities-metrics.png) | [](docs/images/agent-install-join-key.png) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
||||||
@@ -37,93 +41,39 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
- **Web management UI** — manage users, groups, and OAuth clients from a
|
- **Web management UI** — manage users, groups, and OAuth clients from a
|
||||||
browser; invite and password-reset flows over email; user self-service for
|
browser; invite and password-reset flows over email; user self-service for
|
||||||
profile and API tokens.
|
profile and API tokens.
|
||||||
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
|
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
|
||||||
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
|
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
|
||||||
directory, so you don't maintain a second user database for them.
|
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
|
||||||
|
same directory, so you don't maintain a second user database for them.
|
||||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||||
drive the management API from scripts or CI, scoped to their own permissions.
|
drive the management API from scripts or CI, scoped to their own permissions.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows.
|
||||||
the pieces separately against your own LDAP/Redis via `app_*` env config.
|
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||||
|
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers.
|
||||||
|
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
||||||
|
|
||||||
## Why this over the alternatives
|
## Secrets
|
||||||
|
|
||||||
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
Secrets are loaded from **OpenBao** at boot via
|
||||||
LDAP is either a paid feature, a federation target you have to run separately,
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
or absent. If your stack already has apps that speak LDAP directly (or you just
|
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
|
||||||
want one real directory as the source of truth), you end up running *two*
|
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
|
||||||
identity systems and keeping them in sync.
|
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
|
||||||
|
`sso-broker`) — never the root token.
|
||||||
|
|
||||||
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
The SSO also acts as the **vault broker** for the whole stack: it mints
|
||||||
apps and LDAP apps read from the same users and groups. The trade-off is
|
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
|
||||||
scope: it is intentionally small and self-hosted, not an enterprise IAM suite
|
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
|
||||||
— no fancy workflow engine, no hosted SaaS. If you want a lightweight,
|
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
|
||||||
self-contained identity provider with a real LDAP backend, that is the niche.
|
guard) and an admin **Apps** tab to mint scoped tokens for external apps
|
||||||
|
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
|
||||||
|
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
|
||||||
|
writes `secret/sso-manager/conf` through `bao-conf.set`.
|
||||||
|
|
||||||
## Quick start
|
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||||
|
not the authoritative store. For the full architecture, policies, token model,
|
||||||
Three ways to run it, in order of how much it sets up for you:
|
and rotation procedure, see theta-suite's
|
||||||
|
**[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
|
||||||
### 1. As part of the unified stack (recommended)
|
|
||||||
|
|
||||||
[theta-env](https://github.com/theta42/theta-env) composes this SSO Manager with
|
|
||||||
the [theta42/proxy](https://github.com/theta42/proxy) (an OIDC-protected reverse
|
|
||||||
proxy) and generates all the config from a single `setup.env` — you enter your
|
|
||||||
domain once and it fills in the LDAP DNs, hostnames, OAuth issuer, and random
|
|
||||||
secrets consistently:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --recursive https://github.com/theta42/theta-env.git
|
|
||||||
cd theta-env
|
|
||||||
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
|
|
||||||
./setup.sh # generates ./config/, builds + bootstraps + starts both
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [theta-env README](https://github.com/theta42/theta-env) for the full
|
|
||||||
first-run flow, DNS/port requirements, and backups.
|
|
||||||
|
|
||||||
### 2. Standalone, in Docker
|
|
||||||
|
|
||||||
The all-in-one image bundles the app, OpenLDAP, and Redis. Copy the example
|
|
||||||
secrets file, fill in your values, and build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/sso-manager-node.git
|
|
||||||
cd sso-manager-node
|
|
||||||
mkdir -p config && chmod 700 config
|
|
||||||
cp secrets.js.example config/sso-secrets.js
|
|
||||||
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
The web UI comes up at `http://localhost:3001`. To kick the tires with no
|
|
||||||
config file at all, the entrypoint falls back to safe defaults
|
|
||||||
(`dc=example,dc=com`, admin password `admin`, an auto-generated JWT) — fine for
|
|
||||||
a local test, not for production.
|
|
||||||
|
|
||||||
Your domain is entered once, as the LDAP base DN (`stack.ldapBaseDn`); the other
|
|
||||||
LDAP DNs and the OAuth issuer derive from it and must stay consistent. See
|
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) for the full config reference, the `app_*` env
|
|
||||||
vars, LDAPS/TLS, and backups.
|
|
||||||
|
|
||||||
### 3. Bare metal on Debian/Ubuntu
|
|
||||||
|
|
||||||
`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP,
|
|
||||||
configures the directory (modules, overlays, schema, the SSO groups), deploys
|
|
||||||
the app to `/opt/sso-manager`, and creates a systemd unit.
|
|
||||||
|
|
||||||
The only thing it requires is the LDAP admin password; the domain (base DN)
|
|
||||||
defaults to `dc=example,dc=com` if you don't pass one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com'
|
|
||||||
sudo systemctl enable --now sso-manager
|
|
||||||
curl http://localhost:3001/health # -> {"status":"ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT
|
|
||||||
secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run
|
|
||||||
it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2:
|
|
||||||
Bare metal*.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -135,7 +85,7 @@ Bare metal*.
|
|||||||
│ HTTP/HTTPS
|
│ HTTP/HTTPS
|
||||||
▼
|
▼
|
||||||
┌────────────────────────┐ ┌─────────────┐
|
┌────────────────────────┐ ┌─────────────┐
|
||||||
│ Express SSO Manager │◄────►│ Redis │
|
│ Theta Directory │◄────►│ Redis │
|
||||||
│ - OIDC provider │ │ - sessions │
|
│ - OIDC provider │ │ - sessions │
|
||||||
│ - web UI (:3001) │ │ - models │
|
│ - web UI (:3001) │ │ - models │
|
||||||
│ - management API │ └─────────────┘
|
│ - management API │ └─────────────┘
|
||||||
@@ -145,7 +95,7 @@ Bare metal*.
|
|||||||
┌────────────────────────┐
|
┌────────────────────────┐
|
||||||
│ OpenLDAP (slapd) │
|
│ OpenLDAP (slapd) │
|
||||||
│ - users / groups │
|
│ - users / groups │
|
||||||
│ - LDAPS :636 │─── legacy apps bind directly
|
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
|
||||||
│ - StartTLS :389 │
|
│ - StartTLS :389 │
|
||||||
└────────────────────────┘
|
└────────────────────────┘
|
||||||
```
|
```
|
||||||
@@ -158,26 +108,13 @@ required groups, LDAPS/TLS, direct-bind service accounts) live in:
|
|||||||
- [DEPLOYMENT.md](DEPLOYMENT.md) — Docker + bare metal, the config layers, the
|
- [DEPLOYMENT.md](DEPLOYMENT.md) — Docker + bare metal, the config layers, the
|
||||||
`app_*` env reference, LDAPS/TLS, backups, troubleshooting.
|
`app_*` env reference, LDAPS/TLS, backups, troubleshooting.
|
||||||
- [API.md](API.md) — the management API.
|
- [API.md](API.md) — the management API.
|
||||||
- [docs/](docs/) (GitHub Pages) — the same content broken into
|
- [docs/](docs/) — the same content broken into
|
||||||
[deployment](docs/deployment.md), [configuration](docs/configuration.md),
|
[OAuth/OIDC](docs/oauth.md) and [LDAP](docs/ldap.md), also published at the
|
||||||
[OAuth/OIDC](docs/oauth.md), and [LDAP](docs/ldap.md).
|
unified [theta-suite docs site](https://theta42.github.io/theta-suite/sso/).
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
|
||||||
|
- All of the above is also readable from the running app itself at `/docs` —
|
||||||
|
no internet access required.
|
||||||
|
|
||||||
If you are pointing the app at your own existing LDAP server, see
|
|
||||||
*LDAP requirements* in [DEPLOYMENT.md](DEPLOYMENT.md) — the directory needs the
|
|
||||||
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
|
||||||
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
|
||||||
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
|
||||||
OAuth clients only), `app_sso_invite` (invitation management) — see
|
|
||||||
DEPLOYMENT.md for the full setup.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd nodejs
|
|
||||||
npm install
|
|
||||||
npm run dev # nodemon auto-reload
|
|
||||||
npm test # jest test suite
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
|
||||||
|
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
|
||||||
|
// writes the SSO-generated OAuth client creds into it AND into OpenBao
|
||||||
|
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
|
||||||
|
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
|
||||||
|
//
|
||||||
|
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
|
||||||
|
// This .example is tracked to document the expected shape only.
|
||||||
|
module.exports = {
|
||||||
|
oidc: {
|
||||||
|
// The SSO registers the proxy as an OAuth client and writes the real
|
||||||
|
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
|
||||||
|
clientId: 'set-me',
|
||||||
|
clientSecret: 'set-me',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Home-Lab Directory / Inventory — Design Spec
|
# Home-Lab Directory / Inventory — Design Spec
|
||||||
|
|
||||||
Status: **Draft / agreed direction** (no code yet)
|
Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
|
||||||
|
registration from theta-env + ldap-client). §9 adds the planned-consumer
|
||||||
|
readiness review.
|
||||||
Owner: wmantly
|
Owner: wmantly
|
||||||
Last updated: 2026-07-02
|
Last updated: 2026-07-23
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,13 +97,24 @@ common query fields can be promoted to columns later.
|
|||||||
| column | type | notes |
|
| column | type | notes |
|
||||||
|--------------|-------------|-------|
|
|--------------|-------------|-------|
|
||||||
| `id` | uuid / pk | |
|
| `id` | uuid / pk | |
|
||||||
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
|
| `kind` | enum | `site` \| `host` \| `service` |
|
||||||
| `name` | text | display name ("Home Assistant", "ct101") |
|
| `name` | text | display name ("Home Assistant", "ct101") |
|
||||||
| `slug` | text unique | url-safe id used by the API |
|
| `slug` | text unique | url-safe id used by the API |
|
||||||
| `description`| text | free text |
|
| `description`| text | free text |
|
||||||
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` |
|
| `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
|
||||||
| `created_at` / `updated_at` | timestamptz | |
|
| `created_at` / `updated_at` | timestamptz | |
|
||||||
|
|
||||||
|
**Parent Enforcement Rules:**
|
||||||
|
- A **Host** MUST have a parent **Site** or **Host**.
|
||||||
|
- A **Service** MUST have a parent **Host**.
|
||||||
|
- An **OAuth Integration** MUST have a parent **Service**.
|
||||||
|
|
||||||
|
**LDAP Group Auto-Creation:**
|
||||||
|
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
|
||||||
|
- `<slug>_access` (for standard user access)
|
||||||
|
- `<slug>_admin` (for administrative access)
|
||||||
|
Additional groups can still be linked manually.
|
||||||
|
|
||||||
### `resource_edge` — directed relationships (the graph)
|
### `resource_edge` — directed relationships (the graph)
|
||||||
| column | type | notes |
|
| column | type | notes |
|
||||||
|--------------|--------|-------|
|
|--------------|--------|-------|
|
||||||
@@ -109,7 +122,7 @@ common query fields can be promoted to columns later.
|
|||||||
| `child_id` | fk → resource | |
|
| `child_id` | fk → resource | |
|
||||||
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
||||||
|
|
||||||
Represents host←container←service (`hosts`/`runs_on`) and service→service
|
Represents site←host←service (`hosts`/`runs_on`) and service→service
|
||||||
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
||||||
multiple parents/children and multiple relation types.
|
multiple parents/children and multiple relation types.
|
||||||
|
|
||||||
@@ -164,12 +177,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
|
|
||||||
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
||||||
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
||||||
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
|
- **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
|
||||||
token — `AuthToken` is session-oriented. **Proposed small addition:** a
|
|
||||||
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
|
|
||||||
long-lived, read-only, passed in the same `auth-token` header. Track as its own
|
|
||||||
task; the discovery API should assume it exists but degrade to normal auth tokens
|
|
||||||
until then.
|
|
||||||
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
||||||
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
|
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
|
||||||
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
||||||
@@ -195,7 +203,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
## 7. Roadmap
|
## 7. Roadmap
|
||||||
|
|
||||||
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
||||||
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD.
|
`/api/discovery/*` endpoints, `ApiToken` for CI/CD.
|
||||||
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
|
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
|
||||||
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
||||||
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
||||||
@@ -213,4 +221,185 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
|
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
|
||||||
for auth, SQL for inventory.
|
for auth, SQL for inventory.
|
||||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
||||||
5. **Service token scope:** read-only globally, or per-token resource/kind scoping?
|
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Planned consumers — data-model & API readiness
|
||||||
|
|
||||||
|
Five consumers the directory data should be able to power. None are being
|
||||||
|
built yet; this section records what each needs, what already exists, and the
|
||||||
|
gaps to close so the model/API never paints us into a corner.
|
||||||
|
|
||||||
|
The recurring theme: **the graph model itself (Resource / ResourceEdge /
|
||||||
|
ResourceGroup + LDAP groups) is sufficient for all five.** The gaps are
|
||||||
|
(a) one new model (access requests), (b) machine-to-machine auth for the read
|
||||||
|
API, (c) documented metadata conventions instead of new columns, and
|
||||||
|
(d) change detection for the drift/sync consumers.
|
||||||
|
|
||||||
|
### 9.1 End-user exploration ("Netflix-style" catalog + request access)
|
||||||
|
|
||||||
|
A user browses everything that exists — part advertisement, part
|
||||||
|
documentation — sees what they already have, and requests access to the rest.
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- `/api/discovery/me` (`getMyAccess`) — the "My Services" half.
|
||||||
|
- `Resource.owner` + `<slug>_access` / `<slug>_admin` ResourceGroup links —
|
||||||
|
who approves, and which group an approval means joining.
|
||||||
|
- The Notification model — the approval-request delivery mechanism.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Catalog projection with metadata privacy.** `/api/discovery/resources`
|
||||||
|
returns full `metadata` to any authenticated user — including the OAuth
|
||||||
|
kind's `client_secret_hash`, and operator notes that may name internal
|
||||||
|
IPs. Needed: a per-kind public projection (name, description, kind,
|
||||||
|
subType, icon, address, hasAccess, requestable) and a private-key
|
||||||
|
convention for the rest (e.g. only `app_sso_directory_admin` sees full
|
||||||
|
metadata). This is a **fix worth doing before any catalog UI exists**.
|
||||||
|
2. **`AccessRequest` model** — the one genuinely new model:
|
||||||
|
`{id, uid, resourceId, groupCn, status: pending|approved|denied, note,
|
||||||
|
requestedOn, decidedBy, decidedOn}`. Approval = LDAP group add + notify.
|
||||||
|
Endpoints: user POST/GET own; resource owner / directory admin
|
||||||
|
list/approve/deny.
|
||||||
|
3. **Catalog metadata conventions**: `icon`, `tagline` (card-length blurb),
|
||||||
|
`requestable: false` for resources that shouldn't be advertised.
|
||||||
|
|
||||||
|
### 9.2 SSH jump host (`username_-_{hostname-or-ip}@publicHost`)
|
||||||
|
|
||||||
|
A public jump host parses the target out of the SSH username, checks the user
|
||||||
|
may reach that host, and proxies the connection (WinSCP-friendly: one
|
||||||
|
username string, no interactive menu needed — though an interactive picker on
|
||||||
|
plain `username@` login is the same query).
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- Hosts carry `ip` (and `host_<hostname>` slugs to resolve by name).
|
||||||
|
- Access is already group-based (`<slug>_access`), checkable via LDAP alone —
|
||||||
|
the jump host can run entirely off LDAP (SSSD) + one directory query.
|
||||||
|
- User SSH keys are in LDAP (openssh-lpk) — the jump host authenticates the
|
||||||
|
real user without local accounts.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Machine auth for the access query.** The jump host must ask "may user X
|
||||||
|
reach host Y" / "list hosts user X may reach" *about another user*.
|
||||||
|
`getMyAccess` only answers for the calling user. Needed: a
|
||||||
|
service-token-authenticated endpoint (`GET
|
||||||
|
/api/discovery/access/:uid[/:slug]`). `ServiceToken` already exists and
|
||||||
|
is even linked to a resource (`resource_id`) — what's missing is an auth
|
||||||
|
middleware that accepts it and a permission rule ("service tokens may
|
||||||
|
read access info, scoped read-only").
|
||||||
|
2. **Connection metadata conventions** on hosts: `sshPort` (default 22),
|
||||||
|
optional `fqdn` (when IP is dynamic), optional `jumpVia` edge relation if
|
||||||
|
multi-hop topologies ever appear.
|
||||||
|
3. Document the username grammar (`{uid}_-_{host-slug-or-ip}`) here so the
|
||||||
|
seed/ldap-client keep host slugs DNS-safe (they already are: slugify
|
||||||
|
strips everything but `[a-z0-9-]`).
|
||||||
|
|
||||||
|
### 9.3 Firewall port-forward rules (build / update / drift-test)
|
||||||
|
|
||||||
|
An automation renders the public firewall's forwarding table from the
|
||||||
|
directory, applies it, and alerts on drift in either direction.
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- `metadata.port` / `metadata.externalPort` / `metadata.ip` /
|
||||||
|
`metadata.isExternalReachable` — the core mapping data, already seeded for
|
||||||
|
the stack's own services.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Port-mapping convention is too thin for real rules**: no protocol, no
|
||||||
|
multi-port services. Adopt `metadata.portMappings: [{proto: "tcp"|"udp",
|
||||||
|
external: n, internal: n, comment}]` as the authoritative form
|
||||||
|
(`port`/`externalPort` stay as the simple single-mapping case).
|
||||||
|
2. **Drift detection needs cheap change polling**: an `updated_on` timestamp
|
||||||
|
on resources surfaced in the graph API, or a graph-level etag/hash, so
|
||||||
|
the runner can poll without diffing full payloads. (The ORM already
|
||||||
|
publishes create/update events internally — a future push feed can ride
|
||||||
|
that; polling comes first.)
|
||||||
|
3. Same **service-token read auth** as 9.2 — automation must not run on a
|
||||||
|
human's session token.
|
||||||
|
|
||||||
|
### 9.4 Local DNS / mDNS
|
||||||
|
|
||||||
|
A DNS (or mDNS advertiser) zone is generated from the directory: hosts get
|
||||||
|
A records from `metadata.ip`, services get CNAMEs/records from their
|
||||||
|
addresses, sites map to zones.
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- `host_<hostname>` + `ip` covers A records; `site_<name>` is a natural zone
|
||||||
|
boundary; service `address` yields names.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Name conventions**: `metadata.dnsNames: []` for extra aliases, and a
|
||||||
|
documented rule for which name wins (slug vs `address` hostname). TTL
|
||||||
|
only if someone actually needs per-record TTLs — default is fine.
|
||||||
|
2. Same **change detection** as 9.3 (poll `updated_on` / etag; push later).
|
||||||
|
3. Nothing else — this consumer is nearly free once 9.3's conventions land.
|
||||||
|
|
||||||
|
### 9.5 Access control for hosts
|
||||||
|
|
||||||
|
Who may log in to / sudo on which machine, driven by the directory.
|
||||||
|
|
||||||
|
Already there — this is the original point of the system:
|
||||||
|
- `<slug>_access` / `<slug>_admin` groups are auto-provisioned per host;
|
||||||
|
ldap-client configures SSSD/PAM against the directory; `sudoRole` and
|
||||||
|
openssh-lpk schemas cover sudo and SSH keys.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Close the loop in ldap-client**: joined hosts should set an SSSD access
|
||||||
|
filter (`access_provider = ldap`, filter on `host_<hostname>_access`
|
||||||
|
membership) so directory group membership *is* login permission, not just
|
||||||
|
identity. Today the registration exists but enforcement is host-side
|
||||||
|
convention.
|
||||||
|
2. **`accessLevel` granularity**: ResourceGroup's `member`/`owner` maps to
|
||||||
|
login/admin today; if finer roles emerge (e.g. `login` vs `sudo` vs
|
||||||
|
`admin`), extend the enum — the join-table shape already supports it.
|
||||||
|
|
||||||
|
### 9.6 Consolidated work list (model/API only, no consumers)
|
||||||
|
|
||||||
|
Ordered by how much they unblock:
|
||||||
|
|
||||||
|
1. **Metadata privacy projection** on the read API (blocks 9.1; fixes the
|
||||||
|
`client_secret_hash` exposure regardless of any consumer).
|
||||||
|
2. **Service-token auth for `/api/discovery/*`** + `access/:uid` endpoint
|
||||||
|
(blocks 9.2, 9.3; ServiceToken model already exists).
|
||||||
|
3. **`AccessRequest` model + endpoints** (blocks 9.1's request half).
|
||||||
|
4. **Metadata conventions doc entries** (`sshPort`, `portMappings`,
|
||||||
|
`dnsNames`, `icon`, `tagline`, `requestable`) in `docs/directory.md` —
|
||||||
|
conventions, not schema changes; the json column already holds them.
|
||||||
|
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
||||||
|
freshness; trivial once surfaced).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Subtype Management & Metrics Drivers Architecture
|
||||||
|
|
||||||
|
The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols.
|
||||||
|
|
||||||
|
### Subtype Matrix & Drivers
|
||||||
|
|
||||||
|
| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState |
|
||||||
|
| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O |
|
||||||
|
| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status |
|
||||||
|
| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels |
|
||||||
|
| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases |
|
||||||
|
| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP |
|
||||||
|
| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions |
|
||||||
|
|
||||||
|
*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).*
|
||||||
|
|
||||||
|
### 4-Tier Driver Resolution Engine
|
||||||
|
1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource.
|
||||||
|
2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver).
|
||||||
|
3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls.
|
||||||
|
4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts.
|
||||||
|
|
||||||
|
### Subtype Operations Endpoints
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||||
|
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`)
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`)
|
||||||
|
|
||||||
|
## 11. Multi-Site
|
||||||
|
|
||||||
|
This directory can run across multiple sites (one **master** with write authority, any number of **spoke** read-only replicas that stay live-synced after joining), coordinate master promotion, and share the agent-signing key across sites. Full design and operational detail: [`docs/site-join.md`](docs/site-join.md) and, at the suite level, `theta-suite`'s `docs/MULTI_SITE_SPEC.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
|
||||||
|
#
|
||||||
|
# Spins up OpenLDAP + Redis, a real SSO server (bin/www, so the WSS relay is
|
||||||
|
# live), and a client that simulates the agent: it enrolls one, connects over
|
||||||
|
# WSS, sends a real LDAP bind as raw bytes, and verifies the SSO relays it into
|
||||||
|
# OpenLDAP and pipes the response back.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
|
||||||
|
# # exit code 0 = tunnel works; the client prints E2E PASS.
|
||||||
|
|
||||||
|
services:
|
||||||
|
ldap:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=Test SSO
|
||||||
|
command: ["sleep", "infinity"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 5s
|
||||||
|
volumes:
|
||||||
|
- ldap-data:/var/lib/ldap
|
||||||
|
- ldap-certs:/etc/openldap/certs
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
sso:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["node", "bin/www"]
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
||||||
|
- NODE_PORT=3001
|
||||||
|
# Test OpenBao (theta-test-bao) — sso-broker token so the SSO can sign
|
||||||
|
# high-risk agent commands and read node-scoped secrets.
|
||||||
|
- VAULT_ADDR=http://theta-test-bao:8200
|
||||||
|
- VAULT_TOKEN=${VAULT_TOKEN:-}
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["sh", "-c", "seed-test-user && node test/tunnel_e2e.js"]
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
||||||
|
- SSO_URL=http://sso:3001
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
sso:
|
||||||
|
condition: service_started
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ldap-data:
|
||||||
|
ldap-certs:
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# End-to-end test of the real, shipped multi-site join flow (docs/site-join.md).
|
||||||
|
#
|
||||||
|
# Spins up two full all-in-one instances (app + bundled slapd each, like
|
||||||
|
# docker-compose.repl-test.yml) — "master" and "spoke" — plus a client that
|
||||||
|
# drives the actual HTTP API a human/operator would use: mint a site join key
|
||||||
|
# on master, join from spoke, verify the spoke adopted the catalog, went
|
||||||
|
# read-only, and reports live WAN health.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.multisite-e2e.yml up --build --abort-on-container-exit
|
||||||
|
# # exit code 0 = MULTISITE E2E PASS
|
||||||
|
#
|
||||||
|
# slapcat (used by POST /api/site/export) only sees the LDAP data of the
|
||||||
|
# container it runs in, so this MUST use the all-in-one image (master and
|
||||||
|
# spoke each carry their own slapd) — the split ldap+redis+app harness used
|
||||||
|
# by docker-compose.test.yml/e2e.yml won't exercise export/join at all.
|
||||||
|
|
||||||
|
services:
|
||||||
|
master:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
container_name: multisite_e2e_master
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=master,dc=test
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=E2E Master
|
||||||
|
- app_oauth__jwtSecret=e2e-multisite-master-jwt-secret
|
||||||
|
# POST /demote's self-registration (routes/api_site.js) needs a real
|
||||||
|
# reachable endpoint for this container; stack.selfUrl overrides the
|
||||||
|
# normal https://<stack.ssoHost> derivation, which isn't reachable
|
||||||
|
# here (plain HTTP, no TLS/proxy in front, non-443 port).
|
||||||
|
- app_stack__selfUrl=http://master:3001
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 40
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
spoke:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
container_name: multisite_e2e_spoke
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=spoke,dc=test
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=E2E Spoke
|
||||||
|
- app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret
|
||||||
|
- app_stack__selfUrl=http://spoke:3001
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 40
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["sh", "-c", "node test/multisite_join_e2e.js"]
|
||||||
|
environment:
|
||||||
|
- MASTER_URL=http://master:3001
|
||||||
|
- SPOKE_URL=http://spoke:3001
|
||||||
|
- MASTER_LDAP_HOST=master
|
||||||
|
- MASTER_BASE_DN=dc=master,dc=test
|
||||||
|
- SPOKE_LDAP_HOST=spoke
|
||||||
|
- SPOKE_BASE_DN=dc=spoke,dc=test
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
depends_on:
|
||||||
|
master:
|
||||||
|
condition: service_healthy
|
||||||
|
spoke:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
site1:
|
||||||
|
build: .
|
||||||
|
container_name: sso_site1
|
||||||
|
environment:
|
||||||
|
- LDAP_SERVER_ID=1
|
||||||
|
- LDAP_REPLICATION_HOSTS=ldap://site2:389
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
- "10389:389"
|
||||||
|
volumes:
|
||||||
|
- site1-ldap:/var/lib/ldap
|
||||||
|
- site1-redis:/data
|
||||||
|
|
||||||
|
site2:
|
||||||
|
build: .
|
||||||
|
container_name: sso_site2
|
||||||
|
environment:
|
||||||
|
- LDAP_SERVER_ID=2
|
||||||
|
- LDAP_REPLICATION_HOSTS=ldap://site1:389
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
ports:
|
||||||
|
- "3002:3001"
|
||||||
|
- "20389:389"
|
||||||
|
volumes:
|
||||||
|
- site2-ldap:/var/lib/ldap
|
||||||
|
- site2-redis:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
site1-ldap:
|
||||||
|
site1-redis:
|
||||||
|
site2-ldap:
|
||||||
|
site2-redis:
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Docker Compose for running the SSO Manager test suite.
|
||||||
|
#
|
||||||
|
# Spins up:
|
||||||
|
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
|
||||||
|
# redis — Standalone Redis for the app's model/token storage
|
||||||
|
# test-runner — Seeds the test user, then runs `npm test`
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.test.yml up --build
|
||||||
|
# # Or to run a specific test file:
|
||||||
|
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
|
||||||
|
#
|
||||||
|
# The LDAP service uses the same Dockerfile.openldap image as production but
|
||||||
|
# overrides the command to only start slapd + redis (the entrypoint handles
|
||||||
|
# slapd.conf generation, directory initialization, and Redis startup before
|
||||||
|
# running the given command — "sleep infinity" keeps it alive).
|
||||||
|
#
|
||||||
|
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
|
||||||
|
# app_* env vars override conf/secrets.js (highest precedence in
|
||||||
|
# @simpleworkjs/conf), so the production secrets.js is never read.
|
||||||
|
|
||||||
|
services:
|
||||||
|
ldap:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=Test SSO
|
||||||
|
# The entrypoint starts slapd + redis, then runs whatever command is given.
|
||||||
|
# "sleep infinity" keeps the container alive so the test-runner can connect.
|
||||||
|
command: ["sleep", "infinity"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 5s
|
||||||
|
volumes:
|
||||||
|
- ldap-data:/var/lib/ldap
|
||||||
|
- ldap-certs:/etc/openldap/certs
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
test-runner:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
environment:
|
||||||
|
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
|
||||||
|
- NODE_ENV=test
|
||||||
|
# LDAP — point at the ldap service container
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
# Redis — point at the redis service container
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
# JWT secret (required by the app, not sensitive in test)
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
# App name
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ldap-data:
|
||||||
|
ldap-certs:
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
# production, run a dedicated LDAP server and point the app at it via app_*
|
# production, run a dedicated LDAP server and point the app at it via app_*
|
||||||
# env vars (or a mounted conf/secrets.js) using the app-only image.
|
# env vars (or a mounted conf/secrets.js) using the app-only image.
|
||||||
#
|
#
|
||||||
# The app reads its configuration from conf/base.js + conf/secrets.js, deep-merged
|
# The app reads its configuration from conf/base.js + a secrets file, deep-merged
|
||||||
# by @simpleworkjs/conf, with `app_*` environment variables as the
|
# by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
|
||||||
# highest-precedence override layer. This entrypoint exports those `app_*`
|
# with `app_*` environment variables as the highest-precedence override layer.
|
||||||
# vars so the app connects to the bundled slapd without any mounted secrets
|
# This entrypoint exports those `app_*` vars so the app connects to the bundled
|
||||||
# file. Any `app_*` var already set in the environment wins (the values below
|
# slapd without any mounted secrets file. Any `app_*` var already set in the
|
||||||
# are defaults/fallbacks only).
|
# environment wins (the values below are defaults/fallbacks only).
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -33,14 +33,15 @@ error() { echo "[ERROR] $*" >&2; }
|
|||||||
# ── Optional: load operational config from a mounted secrets.js ──────────────
|
# ── Optional: load operational config from a mounted secrets.js ──────────────
|
||||||
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
|
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
|
||||||
# treats it as the authoritative source for the SSO's config (LDAP base, admin
|
# treats it as the authoritative source for the SSO's config (LDAP base, admin
|
||||||
# password, org name, JWT secret, ...). When present, symlink it into
|
# password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
|
||||||
# /app/conf/secrets.js so @simpleworkjs/conf reads it, and override the
|
# so @simpleworkjs/conf reads it directly (no write access to /app/conf
|
||||||
# env-derived operational vars below with the file's values. When absent
|
# needed), and override the env-derived operational vars below with the
|
||||||
# (standalone / env-var deployments) the env vars set above stay in effect and
|
# file's values. When absent (standalone / env-var deployments) the env vars
|
||||||
# the app_* exports further down are emitted as before.
|
# set above stay in effect and the app_* exports further down are emitted as
|
||||||
|
# before.
|
||||||
SECRETS_JS_MODE=0
|
SECRETS_JS_MODE=0
|
||||||
if [[ -f /config/sso-secrets.js ]]; then
|
if [[ -f /config/sso-secrets.js ]]; then
|
||||||
ln -sf /config/sso-secrets.js /app/conf/secrets.js
|
export CONF_SECRETS=/config/sso-secrets.js
|
||||||
SECRETS_JS_MODE=1
|
SECRETS_JS_MODE=1
|
||||||
# Pull the entrypoint's operational vars out of secrets.js in one node call.
|
# Pull the entrypoint's operational vars out of secrets.js in one node call.
|
||||||
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
|
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
|
||||||
@@ -75,11 +76,22 @@ fi
|
|||||||
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
||||||
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
||||||
# path varies by distro; auto-detect rather than hardcode.
|
# path varies by distro; auto-detect rather than hardcode.
|
||||||
|
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
|
||||||
|
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
|
||||||
MODULE_PATH=""
|
MODULE_PATH=""
|
||||||
for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||||
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nested-group support is only available when slapd was built with the
|
||||||
|
# nestgroup overlay. Detect rather than assume, so this entrypoint still
|
||||||
|
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
|
||||||
|
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
|
||||||
|
NESTGROUP_AVAILABLE=0
|
||||||
|
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
|
||||||
|
NESTGROUP_AVAILABLE=1
|
||||||
|
fi
|
||||||
|
|
||||||
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
||||||
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
||||||
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
||||||
@@ -124,6 +136,8 @@ include /etc/openldap/schema/theta42.schema
|
|||||||
include /etc/openldap/schema/sudo.schema
|
include /etc/openldap/schema/sudo.schema
|
||||||
include /etc/openldap/schema/openssh-lpk.schema
|
include /etc/openldap/schema/openssh-lpk.schema
|
||||||
|
|
||||||
|
SERVER_ID_PLACEHOLDER
|
||||||
|
|
||||||
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
|
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
|
||||||
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
|
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
|
||||||
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
||||||
@@ -136,6 +150,9 @@ moduleload pw-sha2
|
|||||||
moduleload ppolicy
|
moduleload ppolicy
|
||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
|
moduleload auditlog
|
||||||
|
NESTGROUP_MODULE_PLACEHOLDER
|
||||||
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||||
# generated/mounted above. We accept clients without their own cert (the common
|
# generated/mounted above. We accept clients without their own cert (the common
|
||||||
@@ -183,6 +200,14 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
refint_attributes memberOf member manager owner
|
||||||
|
|
||||||
|
NESTGROUP_OVERLAY_PLACEHOLDER
|
||||||
|
|
||||||
|
# auditlog overlay (LDIF audit trail of all changes)
|
||||||
|
overlay auditlog
|
||||||
|
auditlog /var/lib/ldap/auditlog.ldif
|
||||||
|
|
||||||
|
REPLICATION_BLOCK_PLACEHOLDER
|
||||||
|
|
||||||
# Access controls
|
# Access controls
|
||||||
access to attrs=userPassword
|
access to attrs=userPassword
|
||||||
by dn="BIND_DN_PLACEHOLDER" write
|
by dn="BIND_DN_PLACEHOLDER" write
|
||||||
@@ -210,6 +235,61 @@ else
|
|||||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Nested groups (nestgroup overlay) ──
|
||||||
|
# Three of the four flags, deliberately:
|
||||||
|
#
|
||||||
|
# member-filter (member=X) finds parent groups transitively. This is what
|
||||||
|
# Group.list(dn) rides on -- the core access question.
|
||||||
|
# memberof-filter (memberOf=X) matches members of nested groups. This is
|
||||||
|
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
|
||||||
|
# nesting without any client-side walking.
|
||||||
|
# memberof-values expands memberOf when reading a user, so anything that
|
||||||
|
# reads the attribute rather than searching still sees the
|
||||||
|
# full picture.
|
||||||
|
#
|
||||||
|
# member-values is deliberately NOT enabled. It expands the `member` attribute
|
||||||
|
# when reading a *group*, which sounds symmetric but destroys the distinction
|
||||||
|
# between "listed on this group" and "reachable through a nested one" -- and
|
||||||
|
# that distinction is not recoverable afterwards, because the raw values are
|
||||||
|
# simply not returned. The Groups UI needs it to show nested groups as nested
|
||||||
|
# rather than as a crowd of phantom users, and un-nesting needs it to know what
|
||||||
|
# it is actually removing. Transitive *answers* come from the filter flags and
|
||||||
|
# from Group.effectiveMembers(), which computes the closure explicitly.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
info "nestgroup overlay available — nested groups resolved server-side"
|
||||||
|
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
|
||||||
|
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
|
||||||
|
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
|
||||||
|
else
|
||||||
|
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
|
||||||
|
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Multi-Master Replication Configuration ──
|
||||||
|
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||||
|
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||||
|
sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
|
||||||
|
sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
|
||||||
|
|
||||||
|
# Generate syncrepl blocks
|
||||||
|
REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
|
||||||
|
RID=100
|
||||||
|
for HOST in ${LDAP_REPLICATION_HOSTS}; do
|
||||||
|
RID=$((RID + 1))
|
||||||
|
REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
|
||||||
|
done
|
||||||
|
REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
|
||||||
|
|
||||||
|
# Replace placeholder (awk is safer for multiline replacements than sed)
|
||||||
|
awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
|
||||||
|
mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
|
||||||
|
else
|
||||||
|
sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
||||||
|
fi
|
||||||
|
|
||||||
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
|
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
|
||||||
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
|
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
|
||||||
|
|
||||||
@@ -219,7 +299,7 @@ info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
|
|||||||
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
||||||
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
||||||
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
||||||
slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" &
|
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
|
||||||
SLAPD_PID=$!
|
SLAPD_PID=$!
|
||||||
|
|
||||||
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
||||||
@@ -267,7 +347,7 @@ objectClass: organizationalRole
|
|||||||
objectClass: pwdPolicy
|
objectClass: pwdPolicy
|
||||||
cn: ppolicy
|
cn: ppolicy
|
||||||
pwdAttribute: 2.5.4.35
|
pwdAttribute: 2.5.4.35
|
||||||
pwdLockout: FALSE
|
pwdLockout: TRUE
|
||||||
pwdMustChange: FALSE
|
pwdMustChange: FALSE
|
||||||
pwdAllowUserChange: TRUE
|
pwdAllowUserChange: TRUE
|
||||||
EOF
|
EOF
|
||||||
@@ -275,7 +355,11 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# non-person accounts -- see the Users page.
|
||||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
#
|
||||||
|
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
||||||
|
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
||||||
|
# the theta-suite bootstrap puts the first admin person into it.
|
||||||
|
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -286,6 +370,27 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nest god_admin into the SSO admin groups, so god admins hold those rights
|
||||||
|
# by membership rather than by a special case in app code. This is what makes
|
||||||
|
# the privilege visible to every consumer -- SSSD, sudo, anything binding
|
||||||
|
# LDAP directly -- instead of only to callers that happen to route through
|
||||||
|
# utils/permission.js.
|
||||||
|
#
|
||||||
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||||
|
# non-person accounts, not a permission, and nesting admins into it would
|
||||||
|
# misclassify them as service accounts on the Users page.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
||||||
|
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
||||||
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
info "Nested god_admin into the SSO admin groups"
|
||||||
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
else
|
else
|
||||||
info "LDAP directory already initialized — skipping seed"
|
info "LDAP directory already initialized — skipping seed"
|
||||||
@@ -345,6 +450,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
|||||||
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
||||||
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
||||||
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
||||||
|
# Tell the app whether slapd resolves nested groups for it. When true the app
|
||||||
|
# trusts a plain (member=) search to be transitive; when false it computes
|
||||||
|
# the closure itself. Getting this wrong in the "true" direction silently
|
||||||
|
# under-grants, so it is derived from the same nestgroup.so probe that
|
||||||
|
# decides whether the overlay is configured at all -- never hardcoded.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
|
||||||
|
fi
|
||||||
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
||||||
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
||||||
# public https URL on the SSO subdomain of the LDAP domain; override with
|
# public https URL on the SSO subdomain of the LDAP domain; override with
|
||||||
|
|||||||
@@ -1,9 +1,55 @@
|
|||||||
title: SSO Manager
|
title: SSO Manager
|
||||||
description: A self-hosted OpenID Connect provider with an OpenLDAP directory and a web management UI
|
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI, for home labs and small businesses that want their own identity provider.
|
||||||
theme: jekyll-theme-cayman
|
url: "https://theta42.github.io"
|
||||||
show_downloads: false
|
baseurl: "/sso-manager-node"
|
||||||
|
logo: /assets/img/theta42.svg
|
||||||
|
lang: en_US
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- jekyll-seo-tag
|
||||||
|
- jekyll-sitemap
|
||||||
|
|
||||||
github:
|
github:
|
||||||
repository_url: https://github.com/theta42/sso-manager-node
|
repository_url: https://github.com/theta42/sso-manager-node
|
||||||
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
|
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
|
||||||
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
|
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
|
||||||
repository_name: theta42/sso-manager-node
|
repository_name: theta42/sso-manager-node
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- title: Home
|
||||||
|
page: /
|
||||||
|
icon: fa-house
|
||||||
|
- title: Deployment
|
||||||
|
page: /deployment.html
|
||||||
|
icon: fa-server
|
||||||
|
- title: Configuration
|
||||||
|
page: /configuration.html
|
||||||
|
icon: fa-gears
|
||||||
|
- title: OAuth
|
||||||
|
page: /oauth.html
|
||||||
|
icon: fa-key
|
||||||
|
- title: LDAP
|
||||||
|
page: /ldap.html
|
||||||
|
icon: fa-address-book
|
||||||
|
- title: Directory
|
||||||
|
page: /directory.html
|
||||||
|
icon: fa-server
|
||||||
|
- title: Plugins
|
||||||
|
page: /plugins.html
|
||||||
|
icon: fa-plug
|
||||||
|
# API.md lives at the repo root, not under docs/, so Jekyll never renders an
|
||||||
|
# api.html for it — link the source directly, same as the Changelog.
|
||||||
|
- title: API
|
||||||
|
url: https://github.com/theta42/sso-manager-node/blob/master/API.md
|
||||||
|
icon: fa-code
|
||||||
|
- title: Changelog
|
||||||
|
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||||
|
icon: fa-list
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
- scope:
|
||||||
|
path: ""
|
||||||
|
type: "pages"
|
||||||
|
values:
|
||||||
|
layout: default
|
||||||
|
image: /assets/img/theta42.svg
|
||||||
|
|||||||
@@ -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,510 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Theta Agent & Endpoint Management
|
||||||
|
nav_order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theta Agent & Endpoint Management
|
||||||
|
|
||||||
|
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Architectures & Operating Systems
|
||||||
|
|
||||||
|
The agent is compiled for 7 target platform binaries with zero external runtime dependencies:
|
||||||
|
|
||||||
|
| Operating System | Architecture | Binary Name | Typical Target Devices |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors |
|
||||||
|
| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra |
|
||||||
|
| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways |
|
||||||
|
| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop |
|
||||||
|
| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro |
|
||||||
|
| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs |
|
||||||
|
| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) |
|
||||||
|
|
||||||
|
The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enrollment
|
||||||
|
|
||||||
|
An agent is only real if the SSO issued its credential. **Tokens the server did
|
||||||
|
not issue are rejected** at the WebSocket handshake.
|
||||||
|
|
||||||
|
There are two ways to get a host enrolled, and the first is the normal one.
|
||||||
|
|
||||||
|
### Join key — install the agent and the host appears
|
||||||
|
|
||||||
|
Hand the machine a **join key** and nothing else. On first connect the SSO
|
||||||
|
enrolls the host, issues it its own per-agent token plus the public key it must
|
||||||
|
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
|
||||||
|
key. From then on it authenticates as itself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --join-key "tjk_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole procedure — no pre-registering the machine, no copying a
|
||||||
|
public key by hand. `setup.sh` mints a key and configures the stack's own host
|
||||||
|
this way automatically.
|
||||||
|
|
||||||
|
The join key is a *bootstrap* credential, not the host's identity. That
|
||||||
|
distinction is what keeps one key convenient without making it a fleet-wide
|
||||||
|
skeleton key: every host still ends up individually revocable, and a compromised
|
||||||
|
host does not yield a credential that works anywhere else.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST /api/agent/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
|
||||||
|
| `DELETE /api/agent/join-keys/:id` | Remove it |
|
||||||
|
| `GET /api/agent/join-keys/:id/agents` | Which hosts enrolled through this key |
|
||||||
|
|
||||||
|
Revoking a join key does **not** disconnect hosts that already joined; they hold
|
||||||
|
their own tokens by then. Revoke the agent itself to cut a specific host off.
|
||||||
|
|
||||||
|
**Reuse.** Yes — a join key is not consumed on use. `AgentJoinKey.authenticate`
|
||||||
|
only checks `revoked` and `expires_on`; it never invalidates the key itself.
|
||||||
|
Every use increments `use_count` and stamps `last_used_on`, but the key keeps
|
||||||
|
working until you revoke or delete it (or it expires) — "one key works for as
|
||||||
|
many hosts as you like" above is literal, not a figure of speech.
|
||||||
|
|
||||||
|
**UI.** The **Install Agent** modal (Directory → Install Agent → Join key tab)
|
||||||
|
has a **Manage join keys** table below the mint/select dropdown: label, prefix,
|
||||||
|
created date, hosts joined, status, and **Revoke**/**Delete** actions per key.
|
||||||
|
Clicking a key's "N hosts" link expands the list of hosts that joined through
|
||||||
|
it (name, online status, joined date, last seen).
|
||||||
|
|
||||||
|
**Audit.** Yes, both halves are logged as structured `"component":"agent"`
|
||||||
|
lines, and the hosts-joined list in the UI above is queryable directly:
|
||||||
|
- Minting: `action: "join_key_issued"` records the acting admin (`actor`),
|
||||||
|
`label`, and `keyPrefix`.
|
||||||
|
- Each enrollment through that key: `action: "join"` records `agentId`,
|
||||||
|
`agentName`, `remoteAddr`, `joinKeyLabel`, and `joinKeyPrefix`.
|
||||||
|
- `GET /api/agent/join-keys/:id/agents` returns the same "which hosts did key
|
||||||
|
X add" answer the UI shows — it matches on the trace `Agent.enroll` leaves in
|
||||||
|
each agent's `description` ("Self-enrolled with join key `<prefix>`") rather
|
||||||
|
than a stored foreign key, since a join key is exchanged for a per-agent
|
||||||
|
token immediately and from then on the agent's own identity is what matters.
|
||||||
|
|
||||||
|
### Pre-registering a host
|
||||||
|
|
||||||
|
When you want the agent bound to a specific Directory host up front, enroll it
|
||||||
|
from **Directory → Install Agent**:
|
||||||
|
|
||||||
|
1. Give the agent a name and **bind it to a host resource**. The binding is what
|
||||||
|
links telemetry, status and commands to a Directory entry.
|
||||||
|
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
|
||||||
|
SHA-256, and shows the raw value **once**.
|
||||||
|
3. Copy the generated install command — it already carries the token and the
|
||||||
|
server's public key.
|
||||||
|
|
||||||
|
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
|
||||||
|
`PUT /api/agent/nodes/:id` or from the Directory.
|
||||||
|
|
||||||
|
Or via the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://<SSO_HOST>/api/agent/enroll \
|
||||||
|
-H "Authorization: Bearer <admin-api-token>" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name": "web01", "resourceId": "<host-resource-uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The response contains `token` (once only) and `publicKey`.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/nodes` | Every enrolled agent, connected or not, plus the server public key |
|
||||||
|
| `POST /api/agent/enroll` | Mint an agent + token |
|
||||||
|
| `PUT /api/agent/nodes/:id` | Rename, or bind/unbind the host resource |
|
||||||
|
| `POST /api/agent/nodes/:id/rotate` | Issue a new token; the old one stops working immediately |
|
||||||
|
| `POST /api/agent/nodes/:id/revoke` | Disable the enrollment |
|
||||||
|
| `DELETE /api/agent/nodes/:id` | Remove the enrollment |
|
||||||
|
| `POST /api/agent/nodes/:id/command` | Send a command (signed automatically when high-risk) |
|
||||||
|
|
||||||
|
Revoke, rotate and delete **drop any live connection immediately** — they do not
|
||||||
|
wait for the agent to reconnect. Commands are addressed by agent **id**, never by
|
||||||
|
token: a token is a credential and has no business in a URL or a log.
|
||||||
|
|
||||||
|
Enrollment, revocation, rotation, every command, and every rejected connection
|
||||||
|
are written to the application log as structured `"component":"agent"` records
|
||||||
|
with the acting user.
|
||||||
|
|
||||||
|
> **Lost the token?** It cannot be recovered — only its hash is stored. Rotate
|
||||||
|
> the agent to issue a new one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Functionality
|
||||||
|
|
||||||
|
### 1. Host Discovery & Inventory
|
||||||
|
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
|
||||||
|
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
|
||||||
|
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
|
||||||
|
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
|
||||||
|
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
|
||||||
|
|
||||||
|
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
|
||||||
|
|
||||||
|
### 2. Real-Time Telemetry Streaming
|
||||||
|
Every 30 seconds, the agent streams real-time performance metrics:
|
||||||
|
- **CPU Load**: System-wide CPU utilization percentage.
|
||||||
|
- **Memory Utilization**: RAM usage percentage and available memory.
|
||||||
|
- **Disk Utilization**: Root filesystem usage percentage.
|
||||||
|
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
|
||||||
|
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Viewing in the SSO Manager
|
||||||
|
|
||||||
|
Agent status and telemetry live on the **Directory** page — there is no separate
|
||||||
|
Agents page. For each **host** resource that has a connected theta-agent, the
|
||||||
|
Directory shows a status dot in the row:
|
||||||
|
|
||||||
|
| Color | Meaning |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
||||||
|
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
|
||||||
|
| **Red** | **Enrolled but not connected.** The agent exists and is expected — this is a fault. |
|
||||||
|
| **Grey** | No agent enrolled for this host, the enrollment is revoked, or the agent service is unreachable. |
|
||||||
|
|
||||||
|
Red and grey used to be the same colour, which made an ordinary directory of
|
||||||
|
hosts look like an outage. Because the enrollment now outlives the connection,
|
||||||
|
"installed but down" is distinguishable from "never had an agent".
|
||||||
|
|
||||||
|
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
|
||||||
|
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
|
||||||
|
|
||||||
|
An agent attaches to its host by its **enrollment binding** (`resourceId`), set
|
||||||
|
when you enroll it or later via `PUT /api/agent/nodes/:id`. Agents enrolled
|
||||||
|
without a binding fall back to matching their reported hostname against the
|
||||||
|
resource name — the old behaviour, kept only as a fallback, because it silently
|
||||||
|
failed whenever a Directory name differed from the machine's hostname and
|
||||||
|
aliased two hosts that happened to share one.
|
||||||
|
|
||||||
|
### Agent discovery feeds the Directory
|
||||||
|
|
||||||
|
A bound agent's discovery payload is written onto its host resource (`os`,
|
||||||
|
`kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`), tagged with
|
||||||
|
`discovery_sources: ["theta-agent"]` and an `agentId` back-reference. An agent
|
||||||
|
runs *on* the host it describes, so it is the most authoritative source the
|
||||||
|
directory has. An unbound agent goes through the normal discovery reconciler
|
||||||
|
instead, matching like any other source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-First Security & Capability Matrix
|
||||||
|
|
||||||
|
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
|
||||||
|
|
||||||
|
| Capability | Config Key | Risk Level | Description & Impact |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
|
||||||
|
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
|
||||||
|
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
|
||||||
|
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
|
||||||
|
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
|
||||||
|
| **LDAP Tunnel** | `ldap_tunnel` | Moderate | Serves a local LDAP byte-pump socket (`ldap_socket`, default `/run/theta/ldap.sock`) for SSSD/PAM. The agent never parses LDAP — it forwards raw bytes to the SSO, which relays them into its own OpenLDAP. |
|
||||||
|
| **Secrets** | `secrets` | Moderate | Renders OpenBao secrets to local files from templates (see [Secrets Engine](#secrets-engine---rendering-openbao-secrets-to-local-files) below). |
|
||||||
|
| **IAM** | `iam` | Critical | Applies SSO-pushed node identity config: sudo rules, SSH `AuthorizedKeysCommand` keys, `/etc/security/access.conf`, and revocation (`sss_cache -E` + session kill). Every push is Ed25519-signed. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Risk Command Verification (Protocol v1.2.0)
|
||||||
|
|
||||||
|
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
|
||||||
|
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace,
|
||||||
|
no HTML escaping, `signature` omitted).
|
||||||
|
2. The payload is signed with the SSO Manager's Ed25519 private key.
|
||||||
|
3. The Base64 signature is appended to the message payload.
|
||||||
|
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
|
||||||
|
|
||||||
|
**The signing key is persistent.** It lives in OpenBao at
|
||||||
|
`secret/agent/signing-key` and survives restarts, so the `public_key` you pin in
|
||||||
|
`agent.yml` keeps matching. (It used to be generated in memory at boot and
|
||||||
|
changed on every restart, which made pinning impossible.) If the SSO cannot load
|
||||||
|
or store a key it **refuses** to send high-risk commands rather than signing with
|
||||||
|
one no agent has seen — `GET /api/agent/nodes` reports this as
|
||||||
|
`signingAvailable: false`.
|
||||||
|
|
||||||
|
This requires the `sso-broker` OpenBao policy to grant `secret/agent/*`. Re-run
|
||||||
|
`./setup.sh` from theta-suite if you are upgrading.
|
||||||
|
|
||||||
|
**Verification is fail-closed on the agent.** An agent with no `public_key`
|
||||||
|
configured rejects every high-risk command. Earlier versions logged "skipping
|
||||||
|
signature verification" and executed them, so an agent installed without a key
|
||||||
|
would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Engine — rendering OpenBao secrets to local files
|
||||||
|
|
||||||
|
The agent can render OpenBao secrets to local files that any process on the
|
||||||
|
host — a bash script, a systemd unit, a Node app, whatever — reads like an
|
||||||
|
ordinary env file. The agent never holds a Vault token: it asks the SSO for the
|
||||||
|
values over its existing WSS channel, and the SSO fetches them from OpenBao
|
||||||
|
using its own access, scoped so the agent can only ever read its own node's
|
||||||
|
secrets.
|
||||||
|
|
||||||
|
**Node scope.** Every path an agent can request must start with
|
||||||
|
`secret/data/nodes/<this-agent's-id>/`. The SSO enforces this server-side
|
||||||
|
(`POST /api/v1/agent/secrets`); a request for any other node's path is
|
||||||
|
rejected:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||||
|
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
|
||||||
|
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
|
||||||
|
```
|
||||||
|
|
||||||
|
A compromised agent can therefore never reach another host's secrets, or
|
||||||
|
anything outside `secret/data/nodes/*`.
|
||||||
|
|
||||||
|
### Walkthrough: a 3rd-party app reads a secret the agent rendered
|
||||||
|
|
||||||
|
This walks through the whole path end to end, on a stack freshly brought up
|
||||||
|
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
|
||||||
|
any theta-suite install.
|
||||||
|
|
||||||
|
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
|
||||||
|
install command on the target host as root.
|
||||||
|
|
||||||
|
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
|
||||||
|
|
||||||
|
On first connect the agent exchanges the join key for its own token + the
|
||||||
|
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
|
||||||
|
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
|
||||||
|
the next step.
|
||||||
|
|
||||||
|
**2. Turn on the `secrets` capability and point it at a template.** Add to the
|
||||||
|
host's `/etc/theta42/agent.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
secrets:
|
||||||
|
- template: /etc/theta/templates/db.env.tpl
|
||||||
|
target: /etc/theta/rendered/db.env
|
||||||
|
reload: "" # optional: e.g. "systemctl reload myapp"
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
secrets: true
|
||||||
|
```
|
||||||
|
|
||||||
|
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
|
||||||
|
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
|
||||||
|
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the agent to pick up the config change.
|
||||||
|
|
||||||
|
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
|
||||||
|
|
||||||
|
```
|
||||||
|
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
|
||||||
|
username=demoapp password=CorrectHorseBattery42
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
|
||||||
|
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
|
||||||
|
node-scope check above). It's idempotent: it skips silently if that path is
|
||||||
|
already seeded.
|
||||||
|
|
||||||
|
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
|
||||||
|
— push it the same way any admin command goes out, `POST
|
||||||
|
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
|
||||||
|
automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
|
||||||
|
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"command": "render_secrets", "payload": {}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent logs `Received command: render_secrets` / `Rendering secret
|
||||||
|
templates...` and atomically writes the target file at mode `0600`:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat /etc/theta/rendered/db.env
|
||||||
|
DB_USER="demoapp"
|
||||||
|
DB_PASS="CorrectHorseBattery42"
|
||||||
|
```
|
||||||
|
|
||||||
|
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
|
||||||
|
among the reported capabilities:
|
||||||
|
|
||||||
|
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
|
||||||
|
|
||||||
|
**5. Read it from a bash app on the same host.** The rendered file is just an
|
||||||
|
env file — no agent involvement needed to consume it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
. /etc/theta/rendered/db.env
|
||||||
|
echo "DB_USER=$DB_USER"
|
||||||
|
echo "DB_PASS=$DB_PASS"
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Read it from a Node app on the same host:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const fs = require('fs');
|
||||||
|
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
|
||||||
|
const db = {};
|
||||||
|
for (const line of env.split('\n')) {
|
||||||
|
const m = /^(\w+)="(.*)"$/.exec(line.trim());
|
||||||
|
if (m) db[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
console.log('DB_USER=' + db.DB_USER);
|
||||||
|
console.log('DB_PASS=' + db.DB_PASS);
|
||||||
|
```
|
||||||
|
|
||||||
|
Both print the same values the template resolved — `demoapp` /
|
||||||
|
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
|
||||||
|
theta-agent repo has these two scripts ready to run.
|
||||||
|
|
||||||
|
### Alternative: calling the API directly
|
||||||
|
|
||||||
|
Rendering to a file is the normal path — it works for any app regardless of
|
||||||
|
language, and the secret never touches an HTTP client the app itself controls.
|
||||||
|
But an app can also fetch its node's secrets directly, bypassing the template
|
||||||
|
engine entirely (useful for debugging, or a process that wants to hold the
|
||||||
|
value only in memory). This uses the **agent's own bearer token**, not an admin
|
||||||
|
token — the same node-scope enforcement applies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||||
|
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
|
||||||
|
fetch('https://sso.example.com/api/v1/agent/secrets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
|
||||||
|
}).then(r => r.json()).then(d => console.log(d.secrets));
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"secrets": {
|
||||||
|
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
|
||||||
|
"username": "demoapp",
|
||||||
|
"password": "CorrectHorseBattery42"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Deployment
|
||||||
|
|
||||||
|
### Quick One-Liner Install
|
||||||
|
Run the following command as `root` on the target Linux host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both values come from enrollment. The **Install Agent** modal builds this line
|
||||||
|
for you with them already filled in. Omitting `--public-key` leaves the agent
|
||||||
|
able to report telemetry but unable to accept any high-risk command.
|
||||||
|
|
||||||
|
### Custom Config Wizard
|
||||||
|
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration File Example (`/etc/theta42/agent.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/theta42/agent.yml
|
||||||
|
server_url: "wss://sso.example.com"
|
||||||
|
# Issued by the SSO. Left empty when installing with a join key -- the agent
|
||||||
|
# fills it in itself once the server enrolls it.
|
||||||
|
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
||||||
|
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
|
||||||
|
# agent once it has its own token.
|
||||||
|
join_key: ""
|
||||||
|
location: "dc-01-rack-12"
|
||||||
|
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
|
||||||
|
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
|
||||||
|
# SPKI blob is 44 bytes, the agent requires 32, and it will refuse every signed
|
||||||
|
# command if this is wrong.
|
||||||
|
public_key: "D0cJB3iuStTzhXlu7tFDh/eEXFxRZwkuwQJJhFSqwlQ="
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
reboot: false
|
||||||
|
service_control: ["nginx", "docker", "sssd"]
|
||||||
|
arbitrary_bash: false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting: agent is rejected (`close 4001`)
|
||||||
|
|
||||||
|
If the agent logs that the server rejected its token, the enrollment — not the
|
||||||
|
network — is the problem. The SSO accepts the WebSocket upgrade and then closes
|
||||||
|
with an application code:
|
||||||
|
|
||||||
|
| Code | Meaning | Fix |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `4001` | Token unknown, or never issued by this server | Enroll the host and put the issued token in `agent.yml` |
|
||||||
|
| `4002` | Superseded — another connection authenticated as this agent | Normal; two copies of the agent are running |
|
||||||
|
| `4003` | Enrollment revoked or deleted | Re-enroll |
|
||||||
|
| `4004` | Token rotated; `agent.yml` has the old value | Copy the new token |
|
||||||
|
|
||||||
|
The agent backs off for 5 minutes on `4001`/`4003`/`4004` rather than retrying
|
||||||
|
every 5 seconds — a credential that is wrong will not fix itself, and hammering
|
||||||
|
the SSO only floods its audit log.
|
||||||
|
|
||||||
|
An agent installed before protocol v1.2.0 carries a token generated in the
|
||||||
|
browser that the server never recorded, so it will be rejected with `4001` until
|
||||||
|
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
|
||||||
|
`join_key` and blank `auth_token` — it will re-enroll itself on the next
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
|
||||||
|
|
||||||
|
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
|
||||||
|
connecting to `wss://<sso-host>/api/agent/ws`, the WebSocket path is usually
|
||||||
|
fine — this is a **network/NAT** problem, not an agent or SSO bug. A host behind
|
||||||
|
the same NAT that owns the SSO often cannot reach its own **public IP** (no
|
||||||
|
hairpin/loopback NAT on many home routers), so the TCP dial times out even
|
||||||
|
though the same address works from outside.
|
||||||
|
|
||||||
|
Fix options:
|
||||||
|
1. Point `agent.yml` `server_url` at an address the host can reach directly —
|
||||||
|
e.g. the SSO host's LAN IP (`http://<lan-ip>` or `http://<lan-ip>:3001` for a
|
||||||
|
no-TLS direct path).
|
||||||
|
2. Enable **NAT reflection / hairpin NAT** on the router so LAN hosts can reach
|
||||||
|
their own public IP:443.
|
||||||
|
3. Add a local route/firewall rule on the agent host for its public IP.
|
||||||
|
|
||||||
|
> Note: on a deployment where the theta42 proxy fronts `sso.suite.example`, make
|
||||||
|
> sure the proxy has a **persistent Host record** for the real SSO domain — not
|
||||||
|
> just the `localtest.me` placeholder — so routing survives a proxy restart
|
||||||
|
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
|
||||||
@@ -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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Configuration
|
title: Configuration
|
||||||
|
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -15,13 +16,27 @@ deep-merges, in order (later wins):
|
|||||||
`localhost`, `SSO Manager`).
|
`localhost`, `SSO Manager`).
|
||||||
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
||||||
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
||||||
4. **`app_*` environment variables** — the highest-precedence layer.
|
4. **`app_*` environment variables** — the highest-precedence layer among these
|
||||||
|
four.
|
||||||
|
|
||||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
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
|
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
|
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
||||||
raw strings otherwise.
|
raw strings otherwise.
|
||||||
|
|
||||||
|
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
|
||||||
|
|
||||||
|
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
|
||||||
|
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
|
||||||
|
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
|
||||||
|
everything else here. On top of that, the admin **Configuration** page in the
|
||||||
|
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
|
||||||
|
and applies the change to the live `conf` object immediately
|
||||||
|
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
|
||||||
|
If a value isn't behaving the way `conf/secrets.js` says it should, check the
|
||||||
|
Configuration UI / OpenBao before assuming a file edit didn't take — it's
|
||||||
|
almost certainly OpenBao (or a live UI edit) winning the merge.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
| Env var | Sets | Type |
|
| Env var | Sets | Type |
|
||||||
@@ -31,6 +46,8 @@ raw strings otherwise.
|
|||||||
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | 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__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__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__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
|
||||||
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | 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_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Deployment
|
title: Deployment
|
||||||
|
description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
---
|
||||||
|
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 nests your resources as a tree, making it easy
|
||||||
|
to comprehend your network topography at a glance. You can filter, search, and
|
||||||
|
sort your entire infrastructure inventory. Click the green `+` icon next to any
|
||||||
|
resource to add a child resource beneath it.
|
||||||
|
|
||||||
|
**Collapsing the tree.** Any resource with children carries a caret; click it to
|
||||||
|
fold that subtree away. The toolbar's double-chevron buttons expand or collapse
|
||||||
|
everything at once. Collapsed state is remembered per browser, so the shape you
|
||||||
|
arrange survives a refresh (and the self-heal reload that follows most edits).
|
||||||
|
|
||||||
|
While a search filter is active every match is shown regardless of collapsed
|
||||||
|
ancestors — otherwise searching for something inside a folded subtree would
|
||||||
|
silently return nothing. Clearing the box restores your saved shape.
|
||||||
|
|
||||||
|
<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 **hosts** for the proxy and jump host (`host_theta-proxy`, `host_theta-jump`)
|
||||||
|
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), OpenResty Edge (the 80/443 data plane), and the SSH Jump Host — each with its address, internal port, and git repo
|
||||||
|
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||||
|
|
||||||
|
Services are parented to the host that actually runs them: Proxy and OpenResty
|
||||||
|
Edge under `host_theta-proxy`, the SSH Jump Host under `host_theta-jump`, and the
|
||||||
|
rest under the stack host. Installs seeded before this was fixed had all of them
|
||||||
|
under the stack host, leaving the two purpose-made host resources childless; the
|
||||||
|
seed re-parents those on its next run, and only when the current parent is the
|
||||||
|
one the old code set, so a layout you arranged deliberately is left alone.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Subtype Management & Metrics Drivers Architecture
|
||||||
|
|
||||||
|
The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management:
|
||||||
|
|
||||||
|
1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`).
|
||||||
|
2. **Specialized Subtype Drivers**:
|
||||||
|
- `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls.
|
||||||
|
- `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks.
|
||||||
|
- `DbDriver`: `postgresql`, `redis`, `openbao_vault`.
|
||||||
|
- `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`.
|
||||||
|
- `K8sDriver`: `k8s_pod`, `k8s_deployment`.
|
||||||
|
3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls.
|
||||||
|
4. **Unmanaged Fallback**: Reports unmanaged status cleanly.
|
||||||
|
|
||||||
|
### Subtype Operations API
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||||
|
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`)
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`)
|
||||||
|
|
||||||
|
## Explicit Secret Inheritance Mode
|
||||||
|
|
||||||
|
Resource secrets stored in OpenBao (`secret/data/resources/<slug>/conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage:
|
||||||
|
|
||||||
|
- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed.
|
||||||
|
- **Explicit Assignment**: Secret pointers (`INHERIT:<parentSlug>:<parentKey>`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services.
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery & Inventory
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery & Inventory
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
The Directory holds two different kinds of thing, and the distinction matters
|
||||||
|
for every consumer of the directory:
|
||||||
|
|
||||||
|
- **Catalog resources** — what you have declared. Created by hand, seeded by
|
||||||
|
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
|
||||||
|
groups, appear in the Catalog, and are the only hosts the
|
||||||
|
[jump host](https://github.com/theta42/jump-host) will connect you to.
|
||||||
|
- **Discovered resources** — what the network reports. Produced by
|
||||||
|
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
|
||||||
|
tab. They are a queue of "this exists, do you want to manage it?", not
|
||||||
|
infrastructure you have committed to.
|
||||||
|
|
||||||
|
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
|
||||||
|
and it has never been promoted. Promoting sets `metadata.managed = true`, at
|
||||||
|
which point it becomes catalog content like any other resource.
|
||||||
|
|
||||||
|
> Nothing grants access to a discovered resource. It carries no groups until it
|
||||||
|
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
|
||||||
|
> guest is not a jump target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where discovered data comes from
|
||||||
|
|
||||||
|
| Source | What it reports |
|
||||||
|
| :--- | :--- |
|
||||||
|
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
|
||||||
|
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
|
||||||
|
| [nmap](plugins.html) | Hosts and open ports on a target range |
|
||||||
|
| [Docker](plugins.html) | Containers on a local or remote daemon |
|
||||||
|
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
|
||||||
|
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
|
||||||
|
|
||||||
|
An agent is the most authoritative of these: it runs *on* the machine it
|
||||||
|
describes. A network scan is the least — it only knows what answered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How results are matched to existing resources
|
||||||
|
|
||||||
|
Every source runs through one reconciler, so two sources seeing the same
|
||||||
|
machine converge on one resource instead of creating duplicates. Matching is
|
||||||
|
tried in order of precision:
|
||||||
|
|
||||||
|
1. **MAC address** — the strongest signal, compared across every interface.
|
||||||
|
2. **IP address** — any address on any interface, plus `metadata.address`.
|
||||||
|
3. **Slug, name, or base hostname** — last resort.
|
||||||
|
|
||||||
|
A candidate must also be **the same kind**. Without that guard a discovered VM
|
||||||
|
named `gitea-runner` would match a hand-created *service* of the same name on
|
||||||
|
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
|
||||||
|
template is the same machine.)
|
||||||
|
|
||||||
|
When a match is found the metadata is merged, interfaces are unioned by MAC, and
|
||||||
|
the source is added to `discovery_sources` — so a resource can legitimately read
|
||||||
|
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
Sources disagree about names, so the most human one wins: a **hostname** beats
|
||||||
|
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
|
||||||
|
tie-break within a rank. This is why a device UniFi knows only as
|
||||||
|
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Plugins emit edges as well as resources (a Proxmox node under its cluster
|
||||||
|
endpoint, a guest under its node). The reconciler refuses any edge that would
|
||||||
|
make a resource its own parent, or that would close a loop — a cycle renders as
|
||||||
|
an infinitely nested tree and breaks every ancestor walk in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promoting a discovered resource
|
||||||
|
|
||||||
|
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
|
||||||
|
pre-filled with what was discovered — name, kind, address, subtype — so you can
|
||||||
|
correct it before committing. Saving marks it managed and provisions its
|
||||||
|
[LDAP groups](groups.html).
|
||||||
|
|
||||||
|
Each row shows what the directory knows about the device: its source(s), its
|
||||||
|
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
|
||||||
|
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
|
||||||
|
looks wrong, that detail is where to start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale results
|
||||||
|
|
||||||
|
Resources that are *only* auto-discovered are garbage-collected: if a source
|
||||||
|
stops reporting one for long enough it is marked
|
||||||
|
`lifecycle_state: "archived"` rather than deleted. Anything you created or
|
||||||
|
promoted is never touched — `manual` in `discovery_sources` exempts it.
|
||||||
|
|
||||||
|
A Proxmox node that is powered off is still reported (with its `status`), so
|
||||||
|
downtime does not look like decommissioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the stack discovers about itself
|
||||||
|
|
||||||
|
`setup.sh` seeds its own components as catalog resources — the site, the stack
|
||||||
|
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
|
||||||
|
discovery plugin then finds the containers backing them. Containers belonging to
|
||||||
|
the theta-suite compose project are recognised and attached to the service they
|
||||||
|
implement rather than appearing as unmanaged strangers, so a fresh install has an
|
||||||
|
empty Discovered Inventory rather than five things demanding attention.
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
- **Site, host, and app slugs never contain `_`.** Normalize to lowercase;
|
||||||
|
spaces and `_` → `-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a
|
||||||
|
site `Main Office` produce slugs `web-01` and `main-office`.
|
||||||
|
- **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
|
||||||
|
specific = f"{site}_{resource.kind}_{resource.slug}_{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` imports:
|
||||||
|
|
||||||
|
```
|
||||||
|
(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access)
|
||||||
|
(cn=main-office_host_web01_admin)
|
||||||
|
(cn=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."
|
||||||
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 320 KiB |
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Home
|
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
|
# SSO Manager
|
||||||
@@ -21,10 +22,11 @@ one command).
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
<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/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/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" 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)*
|
*(click any screenshot to view full size)*
|
||||||
|
|
||||||
@@ -51,10 +53,17 @@ backend, that's the niche.
|
|||||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||||
invite and password-reset flows over email; self-service profile + API
|
invite and password-reset flows over email; self-service profile + API
|
||||||
tokens.
|
tokens.
|
||||||
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
|
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||||
|
directory.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
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/).
|
||||||
|
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
|
||||||
|
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
||||||
|
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
|
||||||
|
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
@@ -74,5 +83,7 @@ That's the standalone quick start. For the full set of install options
|
|||||||
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||||
reverse proxy, designed to sit in front of this SSO.
|
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
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||||
Manager and the proxy together with one command.
|
Manager and the proxy together with one command.
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: LDAP
|
title: LDAP
|
||||||
|
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
|
||||||
---
|
---
|
||||||
|
|
||||||
# LDAP Directory
|
# LDAP Directory
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← 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
|
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)
|
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||||
directly — Gitea, Emby, the theta42/proxy, etc.
|
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||||
|
theta42/proxy, etc.
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|
||||||
@@ -34,6 +40,15 @@ User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
|
|||||||
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
||||||
- `theta42Person` (custom auxiliary; `dateOfBirth`).
|
- `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),
|
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
|
||||||
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
|
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
|
canonical hasher; if you provision users out-of-band, hash passwords the same
|
||||||
@@ -44,18 +59,64 @@ way or use `slappasswd -h '{SSHA512}'`.
|
|||||||
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
||||||
attribute listing member DNs. The `memberOf` overlay populates reverse
|
attribute listing member DNs. The `memberOf` overlay populates reverse
|
||||||
membership (`memberOf` on the user); `refint` keeps it consistent on
|
membership (`memberOf` on the user); `refint` keeps it consistent on
|
||||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
add/remove.
|
||||||
`memberOf` on the user.
|
|
||||||
|
|
||||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
Note that `groupOfNames` requires **at least one member**, which has two
|
||||||
`install.sh`):
|
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 |
|
| 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_admin` | full admin (users, groups, settings) |
|
||||||
| `app_sso_oauth_admin` | OAuth client management |
|
| `app_sso_oauth_admin` | OAuth client management |
|
||||||
| `app_sso_invite` | invitation 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) |
|
| `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)
|
## TLS (LDAPS / StartTLS)
|
||||||
|
|
||||||
@@ -91,35 +152,116 @@ volumes:
|
|||||||
|
|
||||||
The entrypoint leaves existing certs untouched (idempotent).
|
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
|
## Service accounts
|
||||||
|
|
||||||
There are two different kinds of "not a real person" account, and which one
|
A service account is a normal `posixAccount` for something that isn't a
|
||||||
you want depends on what's consuming it:
|
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.
|
||||||
|
|
||||||
**LDAP bind-only** — for an app that just needs to bind LDAP to look users up
|
Create one from the **Users → Service Accounts** tab's "Add new user" form
|
||||||
(its own "LDAP authentication" settings page, or the read-only account
|
with **This is a service account** checked — it skips the birthday/
|
||||||
`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no
|
Terms-of-Service fields a real person's account needs and asks for just an
|
||||||
home directory, can't log into this UI. Create one from the
|
account name. It's flagged (via membership in the `app_sso_service_account`
|
||||||
**Integrations → LDAP** tab's *Service Accounts* section (create, rotate
|
group) so it's listed separately from real people and excluded from "all
|
||||||
password, delete). theta-env's bootstrap creates `cn=ldapclient` this same
|
users" notification broadcasts.
|
||||||
way automatically, and the proxy binds as it — don't reuse the admin DN for
|
|
||||||
this.
|
|
||||||
|
|
||||||
**Unix/POSIX** — for an account something actually *runs as* on a Linux
|
Email and password are both optional for a service account:
|
||||||
host: a media manager, a torrent client, a service like Emby — 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). Create one from the **Users** page'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 a normal `posixAccount`, just flagged (via
|
|
||||||
membership in the `app_sso_service_account` group) so it's visibly marked in
|
|
||||||
the Users list and excluded from "all users" notification broadcasts.
|
|
||||||
|
|
||||||
Either way: don't reuse the admin DN, and give it only the group memberships
|
- No `mail` is set unless you give it one (it never needs a mailbox).
|
||||||
it actually needs.
|
- 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).
|
||||||
|
|
||||||
Example bind test (LDAP bind-only account):
|
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
|
```bash
|
||||||
ldapsearch -x -H ldaps://sso.example.com:636 \
|
ldapsearch -x -H ldaps://sso.example.com:636 \
|
||||||
@@ -199,11 +341,37 @@ needs:
|
|||||||
|
|
||||||
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
||||||
`ppolicy`, `memberof`, `refint`.
|
`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
|
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
||||||
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
||||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
||||||
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
||||||
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`.
|
- **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
|
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
||||||
idempotently against a running slapd (auto-detects the database holding your
|
idempotently against a running slapd (auto-detects the database holding your
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: OAuth / OIDC
|
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
|
# OAuth 2.0 / OpenID Connect
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← 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
|
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
|
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
|
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
||||||
@@ -48,20 +53,18 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
|
|||||||
|
|
||||||
### Managing clients
|
### Managing clients
|
||||||
|
|
||||||
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin`
|
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.
|
||||||
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
|
|
||||||
from a login):
|
|
||||||
|
|
||||||
| Method | Path | Action |
|
| Action | How to do it |
|
||||||
|--------|------|--------|
|
|--------|--------------|
|
||||||
| `GET` | `/api/oauth/client` | list clients |
|
| **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. |
|
||||||
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) |
|
| **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. |
|
||||||
| `GET` | `/api/oauth/client/:id` | get one |
|
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
|
||||||
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups |
|
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
|
||||||
| `DELETE` | `/api/oauth/client/:id` | delete |
|
|
||||||
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) |
|
|
||||||
|
|
||||||
> All client-management endpoints are gated by the `app_sso_oauth_admin` group.
|
> 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
|
## Scopes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
||||||
|
**type** is an installed module; a plugin **instance** is a configured, loadable
|
||||||
|
copy of a type. You can create, edit, load/unload, run, and delete instances
|
||||||
|
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
||||||
|
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
||||||
|
and token on its own schedule.
|
||||||
|
|
||||||
|
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
||||||
|
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
||||||
|
ever shows them masked (`********`); the plugin reads them at run time. This
|
||||||
|
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||||
|
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
||||||
|
|
||||||
|
## Plugin types
|
||||||
|
|
||||||
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||||
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||||
|
`category`. Two built-in categories ship today:
|
||||||
|
|
||||||
|
**`discovery`** — scheduled scans that sync external assets into the
|
||||||
|
directory catalog:
|
||||||
|
|
||||||
|
- `proxmox` — Proxmox VE (URL + API token)
|
||||||
|
- `unifi` — UniFi Network controller (URL + username/password)
|
||||||
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||||
|
- `docker` — Docker daemon discovery (containers as directory resources)
|
||||||
|
|
||||||
|
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
|
||||||
|
notifications:
|
||||||
|
|
||||||
|
- `twilio` — Twilio SMS
|
||||||
|
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
|
||||||
|
Discord, or any HTTP endpoint)
|
||||||
|
|
||||||
|
If no messaging plugin instance is enabled, the system falls back to the
|
||||||
|
legacy `voipms` integration configured directly in the SSO secrets.
|
||||||
|
|
||||||
|
### What the Proxmox plugin produces
|
||||||
|
|
||||||
|
One endpoint becomes one subtree:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox endpoint (cluster name, or the endpoint hostname)
|
||||||
|
└── node (hypervisor)
|
||||||
|
├── VM / template
|
||||||
|
└── LXC / template
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint resource stands for the cluster, not a machine, so it carries the
|
||||||
|
API URL and a `sourceId` but deliberately no IP — giving it the address it is
|
||||||
|
reached at made the reconciler merge it with the node answering on that address,
|
||||||
|
which produced a resource that was its own parent.
|
||||||
|
|
||||||
|
Every guest carries:
|
||||||
|
|
||||||
|
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
|
||||||
|
The MAC and the address on it are read from the same source, so they cannot be
|
||||||
|
mismatched (an earlier version collected MACs and IPs into two flat lists and
|
||||||
|
zipped them by index, which attributed addresses to the wrong NIC on any
|
||||||
|
multi-NIC guest).
|
||||||
|
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
|
||||||
|
has an address.
|
||||||
|
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
|
||||||
|
a directory row traces back to the exact guest on the exact node.
|
||||||
|
|
||||||
|
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
|
||||||
|
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
|
||||||
|
172.x addresses would otherwise give the reconciler spurious matches.
|
||||||
|
|
||||||
|
A stopped VM still reports its MAC (read from the VM config rather than the
|
||||||
|
guest agent), and a DHCP-configured LXC gets its address from the running
|
||||||
|
container's interface list. Offline nodes are recorded with `status` rather than
|
||||||
|
skipped, so a hypervisor that is down does not look decommissioned and get
|
||||||
|
garbage-collected after a week.
|
||||||
|
|
||||||
|
A module exports a **manifest**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// Identity — `type`/`category` default to the file/dir name but can be set
|
||||||
|
// explicitly. `name`/`description` show up in the UI.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
||||||
|
|
||||||
|
// Drives the admin UI form, API validation, and secret masking. Fields with
|
||||||
|
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button: validate the config (don't do the work). Return
|
||||||
|
// { ok: true } or { ok: false, error: '...' }. Optional.
|
||||||
|
validate: async (config) => { … },
|
||||||
|
|
||||||
|
// The work. `run` is the generalized contract name; the discovery plugins
|
||||||
|
// also keep `discover` as an alias for back-compat. For `category:
|
||||||
|
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
||||||
|
run: async (config) => { return { resources, edges }; },
|
||||||
|
discover: async (config) => { return { resources, edges }; }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`run(config)` receives the merged non-secret config + secret values as one flat
|
||||||
|
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
||||||
|
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
||||||
|
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
||||||
|
|
||||||
|
### Writing a custom plugin type
|
||||||
|
|
||||||
|
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
||||||
|
following the manifest above. New types are picked up at boot, so restart the
|
||||||
|
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
||||||
|
adding a new type still needs a restart.
|
||||||
|
|
||||||
|
## The Plugins page
|
||||||
|
|
||||||
|
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
||||||
|
/ `app_super_admin`):
|
||||||
|
|
||||||
|
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
||||||
|
source name + the URL the resource graph attributes results to), set a cron
|
||||||
|
schedule, and fill in the config form (secret fields are password inputs).
|
||||||
|
Creating it schedules it and kicks one immediate run.
|
||||||
|
- **Edit** — name, cron, and non-secret config.
|
||||||
|
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
||||||
|
field blank to keep its current value.
|
||||||
|
- **Test** (vial icon) — runs the plugin's `validate`.
|
||||||
|
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
||||||
|
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
||||||
|
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
||||||
|
secret values masked.
|
||||||
|
|
||||||
|
| Method + path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
||||||
|
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
||||||
|
| `GET /api/plugins/:id` | one instance |
|
||||||
|
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
||||||
|
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
||||||
|
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
||||||
|
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
||||||
|
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
||||||
|
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
||||||
|
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
||||||
|
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
||||||
|
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
||||||
|
|
||||||
|
## Scheduler internals
|
||||||
|
|
||||||
|
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
||||||
|
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
||||||
|
that one schedule without disturbing the others. A daily `garbage_collect` job
|
||||||
|
prunes discovery resources not seen in > 7 days.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
|
||||||
|
Before this system, plugins were configured statically in `sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
||||||
|
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
||||||
|
type is seeded automatically (secret fields copied into OpenBao). After that the
|
||||||
|
table is non-empty and the static config is ignored — manage plugins from the
|
||||||
|
UI/API instead. The migration is idempotent (guarded by the empty-table check).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
|
||||||
|
The container's entrypoint reads two environment variables to configure this
|
||||||
|
-- `LDAP_SERVER_ID` (a unique integer for this node) and
|
||||||
|
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
|
||||||
|
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
|
||||||
|
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
|
||||||
|
in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
|
**If you're using `theta-suite`'s `setup.sh`, you don't set these by hand.**
|
||||||
|
The master assigns each spoke a unique `LDAP_SERVER_ID` at join time (the
|
||||||
|
same way it assigns a WireGuard mesh index), and `LDAP_REPLICATION_HOSTS` is
|
||||||
|
derived automatically from every site's already-known HTTPS endpoint
|
||||||
|
(`ldaps://<same-host>:636`) -- see `GET /api/site/ldap-peers` (spoke) and
|
||||||
|
`GET /api/directory-admin/ldap-replication-config` (master), and
|
||||||
|
`theta-suite`'s `bootstrap/site-ldap-register.js`, which re-checks on every
|
||||||
|
`setup.sh` run since the peer list changes as new spokes join.
|
||||||
|
|
||||||
|
Setting the two env vars directly still works (e.g. a non-`theta-suite`
|
||||||
|
deployment) -- example using three manually-configured nodes:
|
||||||
|
|
||||||
|
**Site 1**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=1
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 2**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=2
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 3**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=3
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**A known limitation of the automatic path**: the *master's* own
|
||||||
|
`LDAP_REPLICATION_HOSTS` only gets recomputed when its `setup.sh` is
|
||||||
|
re-run (or the operator re-applies it directly) -- there's no live push
|
||||||
|
telling the master's already-running container about a spoke that joined
|
||||||
|
five minutes ago. A spoke's own config, by contrast, is re-checked and
|
||||||
|
applied on every `setup.sh` run there, which is the common/recurring event.
|
||||||
|
Re-run `setup.sh` on the master after bringing up a new spoke to pick up the
|
||||||
|
new peer and restart replication with it.
|
||||||
|
|
||||||
|
## 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,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Multi-Site: Joining a Spoke to the Master Directory
|
||||||
|
|
||||||
|
The Directory can be deployed across multiple sites. The **master** site holds
|
||||||
|
single write authority for the shared catalog; **spoke** sites run a read-only
|
||||||
|
copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
|
||||||
|
full architecture). This page covers the server endpoints that make a spoke
|
||||||
|
"join" an existing master.
|
||||||
|
|
||||||
|
> Status: **server endpoints + UI + setup.sh wiring, live replication, coordinated promotion.** A fresh bring-up can adopt a master directory via the Directory UI or via `setup.env`; a joined spoke is read-only with live WAN health, stays in sync after joining (not just a one-time snapshot), and can be promoted to master with the old master demoted as part of the same action.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
|
||||||
|
stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**.
|
||||||
|
2. On the **spoke** (a fresh install), either:
|
||||||
|
- **UI**: Directory → the Master Site modal → **Join an Existing Site**, or
|
||||||
|
- **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`
|
||||||
|
in `setup.env` before the first run.
|
||||||
|
3. The spoke pulls the master's directory export (LDAP tree + resource
|
||||||
|
catalog + agent-signing key), imports it, and persists its own spoke role
|
||||||
|
(`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`.
|
||||||
|
4. If the spoke also knows its own reachable URL (`selfUrl` — `setup.sh` passes
|
||||||
|
`https://$CFG_SSO_HOST` automatically), it registers itself with the master
|
||||||
|
(`POST /api/site/spokes`) so the master can push live updates back to it
|
||||||
|
afterward — see **Live replication** below. Without `selfUrl` the join still
|
||||||
|
succeeds; the spoke just stays a one-time snapshot.
|
||||||
|
|
||||||
|
Joining is allowed only on a **fresh install** (no users beyond the bootstrap
|
||||||
|
admin, no enrolled agents) — the join endpoint enforces this, so a populated
|
||||||
|
directory can never be merged into a master's.
|
||||||
|
|
||||||
|
## Live replication (not a one-time snapshot)
|
||||||
|
|
||||||
|
A registered spoke stays in sync: every successful catalog write on the
|
||||||
|
master fires a fire-and-forget push (`utils/site_replicate.js`) at every
|
||||||
|
registered spoke, concurrently — one unreachable spoke never blocks or delays
|
||||||
|
delivery to another. The spoke's `POST /api/site/resync` handler (called by
|
||||||
|
that push) re-runs the same export-pull-and-import logic used at join time,
|
||||||
|
so there's exactly one tested code path for "make my catalog match the
|
||||||
|
master's," not a separate diff-application mechanism.
|
||||||
|
|
||||||
|
The agent-signing key travels the same path: `POST /api/site/export`
|
||||||
|
best-effort includes it, and the spoke adopts it via `agent_keys.adopt()` on
|
||||||
|
both join and every resync. Every site holding the same signing key means any
|
||||||
|
site's `sso-manager-node` can validly sign a command for any agent enrolled
|
||||||
|
at any other site — a deliberate tradeoff (see `MULTI_SITE_SPEC.md` §2)
|
||||||
|
accepted for this deployment's small, trusted scale. Don't extend this
|
||||||
|
pattern to a larger/adversarial-tenant deployment without revisiting it.
|
||||||
|
|
||||||
|
## Coordinated master promotion
|
||||||
|
|
||||||
|
`POST /api/directory-admin/site-promote` (`god_admin` only) promotes this
|
||||||
|
node to master as **one coordinated action**, not a manual two-step
|
||||||
|
demote-then-promote:
|
||||||
|
|
||||||
|
1. If this node currently has a master on file, it mints a fresh join key and
|
||||||
|
calls that master's `POST /api/site/demote` (authenticated with the join
|
||||||
|
key this node already holds), handing over the new key so the demoted node
|
||||||
|
can keep talking to the new master afterward.
|
||||||
|
2. This step is **best-effort** — an unreachable old master (the WAN-outage
|
||||||
|
scenario this whole control exists for) never blocks the local promotion.
|
||||||
|
The response's `handoff` field reports what happened
|
||||||
|
(`"previous master demoted"`, an HTTP failure, or "unreachable, promoted
|
||||||
|
locally anyway") so the operator can reconcile it manually if needed.
|
||||||
|
3. Every known spoke gets a fire-and-forget `master-promoted` resync ping so
|
||||||
|
they pick up the new master on their next sync.
|
||||||
|
|
||||||
|
The Master Site modal's **Promote to Master** button surfaces the `handoff`
|
||||||
|
result in a toast so the operator sees immediately whether the old master was
|
||||||
|
actually reached.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST` | `/api/site/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
|
||||||
|
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
|
||||||
|
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
|
||||||
|
| `POST` | `/api/site/export` | Master directory export incl. agent-signing key (Bearer `stj_` key) |
|
||||||
|
| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) |
|
||||||
|
| `POST` | `/api/site/join` | Adopt a master directory + register for live replication (admin session) |
|
||||||
|
| `POST` | `/api/site/spokes` | Register a spoke's endpoint for live replication (Bearer `stj_` key, called by the spoke right after join) |
|
||||||
|
| `POST` | `/api/site/resync` | Re-pull the master's export (Bearer the spoke's own `pushToken`, called by the master's fire-and-forget push) |
|
||||||
|
| `POST` | `/api/site/demote` | Step down to spoke of a new master (Bearer `stj_` key, called by the newly-promoted node) |
|
||||||
|
| `POST` | `/api/directory-admin/site-promote` | Promote this node to master, coordinating demotion of the old one (`god_admin` session) |
|
||||||
|
|
||||||
|
## Behavior after joining (spoke)
|
||||||
|
|
||||||
|
- **Read-only**: directory-write requests (resources, edges, groups, secrets,
|
||||||
|
grants, driver actions, discovery merges) are rejected with `403` pointing at
|
||||||
|
the master. Writes must go to the master.
|
||||||
|
- **WAN health**: `site-status` pings the master over the stored site join key
|
||||||
|
and reports `wanConnected`; the Master Site modal shows live Online/Offline.
|
||||||
|
- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json`
|
||||||
|
(the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so
|
||||||
|
a restart never silently reverts a spoke to master.
|
||||||
|
|
||||||
|
## Deployment (setup.sh)
|
||||||
|
|
||||||
|
`setup.env` carries the intent so the join runs only on a **fresh** bring-up:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
|
||||||
|
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||||
|
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after
|
||||||
|
the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that
|
||||||
|
already joined reports "already a spoke" and setup continues (idempotent).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
|
||||||
|
hash, revocable/expirable — the same model as agent join keys.
|
||||||
|
- The export/ping endpoints return only the directory tree/catalog (no admin
|
||||||
|
secrets) and require a valid join key.
|
||||||
|
- Join is admin-gated on the spoke, key-gated on the master, and fresh-install
|
||||||
|
gated on both sides.
|
||||||
|
- The join key is stored on the spoke only so it can reach the master for WAN
|
||||||
|
health (and, in a later layer, write-proxy).
|
||||||
|
- `pushToken` (the credential a spoke stores so it can recognize a legitimate
|
||||||
|
resync push from its master) is minted fresh per spoke registration and, by
|
||||||
|
design, kept in retrievable form on the master — unlike a join key, it's a
|
||||||
|
credential the master must keep *presenting*, not just verifying, so it
|
||||||
|
can't be one-way hashed. Compare `models/site_spoke.js`'s doc comment for
|
||||||
|
why that's the correct tradeoff, not an oversight.
|
||||||
|
- Every site sharing one agent-signing key (see **Live replication** above)
|
||||||
|
means a compromised spoke — including the smallest, least-secured one — has
|
||||||
|
the same agent-command authority as the master. Accepted for this
|
||||||
|
deployment's scale; see `MULTI_SITE_SPEC.md` §2 before reusing this pattern
|
||||||
|
somewhere that assumption doesn't hold.
|
||||||
|
|
||||||
|
## Not yet built
|
||||||
|
|
||||||
|
- OpenBao secret replication covers only the agent-signing key; LDAP admin
|
||||||
|
creds, JWT secret, and other per-deployment secrets aren't synced.
|
||||||
|
- A promoted spoke's own OpenLDAP `ServerID` doesn't apply live -- `POST
|
||||||
|
/site-promote` starts advertising `1` for it immediately
|
||||||
|
(`GET /directory-admin/ldap-replication-config`), but nothing restarts
|
||||||
|
`slapd` with that value automatically (its static `slapd.conf` is only
|
||||||
|
read at process start). Re-run `setup.sh` on the newly-promoted node
|
||||||
|
promptly after promotion to actually apply it.
|
||||||
|
- The master's own `LDAP_REPLICATION_HOSTS` peer list only recomputes on
|
||||||
|
its next `setup.sh` run, not live the instant a new spoke joins -- same
|
||||||
|
re-run-`setup.sh` caveat as above, just triggered by a join instead of a
|
||||||
|
promotion.
|
||||||
|
|
||||||
|
## Shipped since the above was last stale
|
||||||
|
|
||||||
|
- Traffic between sites (`utils/site_replicate.js`'s resync push) prefers a
|
||||||
|
registered spoke's WireGuard mesh IP over the open internet when one's on
|
||||||
|
file, falling back to the public endpoint on failure.
|
||||||
|
- A no-inbound spoke (no public IP at all) CAN join: `noInbound`/`meshIp`/
|
||||||
|
`publicHost` on `POST /api/site/join` drive `utils/proxy_client.js`, which
|
||||||
|
auto-creates/updates the relay route on the master's own `theta-proxy`.
|
||||||
|
Mesh peering between the two jump-hosts is still a manual, one-time step
|
||||||
|
(see `theta-suite`'s `spoke.env.example` for the operator-facing side).
|
||||||
|
A spoke with zero inbound *and* zero outbound path still can't join --
|
||||||
|
the join itself needs to reach the master's API directly.
|
||||||
|
- OpenLDAP N-way multi-master replication now auto-configures on join --
|
||||||
|
the master assigns each spoke a unique `LDAP_SERVER_ID` and derives every
|
||||||
|
site's `ldaps://` URL automatically (`GET /api/site/ldap-peers`,
|
||||||
|
`GET /directory-admin/ldap-replication-config`). See
|
||||||
|
`docs/replication.md`.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Vault Secrets
|
||||||
|
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||||
|
|
||||||
|
## Location & Access
|
||||||
|
|
||||||
|
- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps/<name>/*`.
|
||||||
|
- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources/<slug>/conf`.
|
||||||
|
|
||||||
|
## External App Tokens (Admin)
|
||||||
|
|
||||||
|
The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao.
|
||||||
|
|
||||||
|
1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*`.
|
||||||
|
3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically.
|
||||||
|
|
||||||
|
The token is strictly scoped to `secret/apps/<name>/*` (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
|
|
||||||
|
## Shared tab
|
||||||
|
|
||||||
|
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
|
||||||
|
|
||||||
|
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
|
||||||
|
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
|
||||||
|
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
|
||||||
|
|
||||||
|
## API Access
|
||||||
|
|
||||||
|
To read your own secrets programmatically, call the `/api/vault` proxy with
|
||||||
|
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
|
||||||
|
token. The server authenticates the request, resolves your own scoped
|
||||||
|
OpenBao access, and injects the real `X-Vault-Token` itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
|
||||||
|
curl -H "Authorization: Bearer sso_<id>_<secret>" \
|
||||||
|
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
An **external app** reading its own config uses the scoped token minted for
|
||||||
|
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
|
||||||
|
above for how that token is minted and what it's confined to.
|
||||||
|
|
||||||
|
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
|
||||||
|
never the intended path for day-to-day secret access — it's an
|
||||||
|
operator/maintenance credential (seeding, disaster recovery), kept in
|
||||||
|
`setup.env` and never passed to a service container. See
|
||||||
|
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
|
||||||
|
for the full token/policy model.
|
||||||
@@ -1,719 +1,230 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# install.sh - Idempotent standalone installer for Theta42 SSO Manager
|
|
||||||
# For Debian/Ubuntu systems
|
|
||||||
#
|
#
|
||||||
# This script:
|
# Install / update Theta42 SSO Manager on a fresh or existing host.
|
||||||
# 1. Installs Node.js 20.x
|
|
||||||
# 2. Installs and configures OpenLDAP with required schemas/overlays
|
|
||||||
# 3. Deploys the SSO Manager application
|
|
||||||
# 4. Sets up systemd services
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# This script is idempotent: run it to install, and re-run it to update. It
|
||||||
# sudo ./install.sh [OPTIONS]
|
# installs system dependencies (Node, OpenLDAP, Redis), force-syncs the repo at
|
||||||
|
# $REPO_DIR to its remote branch, and symlinks the systemd config straight from
|
||||||
|
# the repo. Because the config is symlinked, an update is just "sync the repo +
|
||||||
|
# restart" -- the files under /etc/systemd always track the repo.
|
||||||
#
|
#
|
||||||
# Options:
|
# Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default),
|
||||||
# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env)
|
# outside the repo checkout so they survive the hard reset below. FIRST RUN
|
||||||
# -b, --base-dn DN Base DN (default: dc=example,dc=com)
|
# ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules,
|
||||||
# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager)
|
# overlays, custom schema, directory tree, required SSO groups -- see
|
||||||
# -o, --port PORT HTTP port for SSO Manager (default: 3001)
|
# ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless
|
||||||
# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated)
|
# given via env, and seeds $SECRETS_FILE with those values plus SMTP
|
||||||
# -s, --smtp-config CONFIG SMTP config as host:port:user:pass
|
# placeholders. Edit that file (SMTP, org name, ...) and re-run this script to
|
||||||
# --skip-ldap Skip LDAP installation (use existing LDAP)
|
# apply changes -- once it exists it is never touched again, and LDAP is never
|
||||||
# --skip-app Skip application installation (LDAP setup only)
|
# re-bootstrapped.
|
||||||
# --dry-run Show what would be done without making changes
|
|
||||||
# -h, --help Show this help
|
|
||||||
#
|
#
|
||||||
# Environment variables (alternative to flags):
|
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
|
||||||
# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_*
|
# hard-reset to origin/$BRANCH on every run, so the box deterministically
|
||||||
|
# mirrors the repo (any drift on the box is discarded).
|
||||||
|
#
|
||||||
|
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
|
||||||
|
# SECRETS_FILE=, LDAP_BASE_DN=, LDAP_ADMIN_PASS=,
|
||||||
|
# JWT_SECRET=, ORG_NAME=, PORT=, SKIP_LDAP=true)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# Never block on an interactive git credential prompt in CI.
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
|
||||||
|
# dependency of redis-server/slapd on a box that's never configured it).
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}"
|
||||||
BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
|
REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}"
|
||||||
ADMIN_PASS="${LDAP_ADMIN_PASS:-}"
|
BRANCH="${BRANCH:-master}"
|
||||||
|
NODE_MAJOR=22
|
||||||
|
SECRETS_FILE="${SECRETS_FILE:-/etc/sso-manager/secrets.js}"
|
||||||
|
|
||||||
|
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
|
||||||
ORG_NAME="${ORG_NAME:-SSO Manager}"
|
ORG_NAME="${ORG_NAME:-SSO Manager}"
|
||||||
PORT="${PORT:-3001}"
|
PORT="${PORT:-3001}"
|
||||||
JWT_SECRET="${JWT_SECRET:-}"
|
|
||||||
SMTP_HOST="${SMTP_HOST:-}"
|
|
||||||
SMTP_PORT="${SMTP_PORT:-587}"
|
|
||||||
SMTP_USER="${SMTP_USER:-}"
|
|
||||||
SMTP_PASS="${SMTP_PASS:-}"
|
|
||||||
SKIP_LDAP="${SKIP_LDAP:-false}"
|
SKIP_LDAP="${SKIP_LDAP:-false}"
|
||||||
SKIP_APP="${SKIP_APP:-false}"
|
|
||||||
DRY_RUN="${DRY_RUN:-false}"
|
|
||||||
|
|
||||||
INSTALL_DIR="/opt/sso-manager"
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
SYSTEMD_DIR="/etc/systemd/system"
|
echo "This script must be run as root (try: sudo $0)" >&2
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
exit 1
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
|
||||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
|
|
||||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
||||||
dry_run() { if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] $*"; fi; }
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
grep '^#' "$0" | sed 's/^# \{0,1\}//'
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
-p|--admin-pass)
|
|
||||||
ADMIN_PASS="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-b|--base-dn)
|
|
||||||
BASE_DN="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-n|--org-name)
|
|
||||||
ORG_NAME="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-o|--port)
|
|
||||||
PORT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-j|--jwt-secret)
|
|
||||||
JWT_SECRET="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-s|--smtp-config)
|
|
||||||
IFS=':' read -r SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS <<< "$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--skip-ldap)
|
|
||||||
SKIP_LDAP="true"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--skip-app)
|
|
||||||
SKIP_APP="true"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
|
||||||
DRY_RUN="true"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
error "Unknown option: $1"
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Validate required parameters
|
|
||||||
if [[ -z "$ADMIN_PASS" ]]; then
|
|
||||||
error "LDAP admin password is required (-p or LDAP_ADMIN_PASS env)"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate JWT secret if not provided
|
# Symlink $1 -> $2, replacing whatever is already at $2 (idempotent).
|
||||||
if [[ -z "$JWT_SECRET" ]]; then
|
link(){
|
||||||
JWT_SECRET=$(openssl rand -hex 32)
|
ln -sfn "$1" "$2"
|
||||||
info "Generated JWT secret: ${JWT_SECRET:0:8}..."
|
echo "linked $2 -> $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read the "version" field out of a package.json without depending on Node
|
||||||
|
# being installed yet (this runs before the Node.js install step below).
|
||||||
|
pkg_version(){
|
||||||
|
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installed version before this run touches anything, for the upgrade banner
|
||||||
|
# at the end. Empty on a fresh install (no prior checkout).
|
||||||
|
CURRENT_VERSION=""
|
||||||
|
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
|
||||||
|
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email
|
# FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever
|
||||||
# sender defaults. Override with LDAP_DOMAIN if set.
|
# happen once, the first time this script runs on a host (i.e. before
|
||||||
if [[ -z "${LDAP_DOMAIN:-}" ]]; then
|
# $SECRETS_FILE exists). Every later run only updates the code.
|
||||||
LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g')
|
FIRST_RUN=0
|
||||||
|
[ -f "$SECRETS_FILE" ] || FIRST_RUN=1
|
||||||
|
|
||||||
|
echo "==> Base packages"
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential redis-server \
|
||||||
|
wget gnupg ca-certificates curl git
|
||||||
|
|
||||||
|
echo "==> Node.js ${NODE_MAJOR}.x apt source"
|
||||||
|
install -d -m 0755 /etc/apt/keyrings
|
||||||
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
||||||
|
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
|
||||||
|
> /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
|
echo "==> Install Node.js"
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs
|
||||||
|
|
||||||
|
echo "==> Redis"
|
||||||
|
systemctl enable --now redis-server
|
||||||
|
|
||||||
|
echo "==> Repo checkout at ${REPO_DIR} (branch ${BRANCH})"
|
||||||
|
install -d "$(dirname "$REPO_DIR")"
|
||||||
|
if [ -d "$REPO_DIR/.git" ]; then
|
||||||
|
# Force the box to match the remote branch exactly. No human edits configs
|
||||||
|
# on prod, so discarding local drift is the desired, deterministic behavior.
|
||||||
|
git -C "$REPO_DIR" fetch --prune origin
|
||||||
|
git -C "$REPO_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
|
||||||
|
git -C "$REPO_DIR" reset --hard "origin/$BRANCH"
|
||||||
|
git -C "$REPO_DIR" clean -fd
|
||||||
|
else
|
||||||
|
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── System checks ─────────────────────────────────────────────────────────────
|
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||||
check_root() {
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then
|
||||||
error "This script must be run as root (sudo)"
|
echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})"
|
||||||
exit 1
|
LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}"
|
||||||
fi
|
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||||
}
|
BIND_DN="cn=admin,${LDAP_BASE_DN}"
|
||||||
|
# slapd/domain wants a dotted DNS domain (e.g. "example.com"), not the raw
|
||||||
check_os() {
|
# DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw
|
||||||
if [[ ! -f /etc/debian_version ]]; then
|
# DN with only the leading "dc=" stripped) makes slapd's postinst hang
|
||||||
error "This script is for Debian/Ubuntu systems only"
|
# indefinitely instead of failing cleanly.
|
||||||
exit 1
|
LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')"
|
||||||
fi
|
|
||||||
info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)"
|
if ! command -v slapd >/dev/null 2>&1; then
|
||||||
}
|
debconf-set-selections <<-EOF
|
||||||
|
slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS}
|
||||||
# ── Package installation ──────────────────────────────────────────────────────
|
slapd slapd/password1 password ${LDAP_ADMIN_PASS}
|
||||||
install_package() {
|
slapd slapd/password2 password ${LDAP_ADMIN_PASS}
|
||||||
local pkg="$1"
|
slapd slapd/domain string ${LDAP_DOMAIN}
|
||||||
if dpkg -l | grep -q "^ii $pkg "; then
|
slapd shared/organization string ${ORG_NAME}
|
||||||
info "Package $pkg is already installed"
|
slapd slapd/purge_database boolean true
|
||||||
return 0
|
slapd slapd/move_old_database boolean true
|
||||||
fi
|
EOF
|
||||||
dry_run "Would install package: $pkg"
|
apt-get install -y slapd ldap-utils
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
cat > /etc/ldap/ldap.conf <<-EOF
|
||||||
apt-get update -qq
|
BASE ${LDAP_BASE_DN}
|
||||||
apt-get install -y -qq "$pkg"
|
URI ldap://localhost
|
||||||
info "Installed $pkg"
|
EOF
|
||||||
}
|
systemctl enable --now slapd
|
||||||
|
else
|
||||||
install_nodejs() {
|
echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}"
|
||||||
if command -v node &>/dev/null && node --version | grep -q "v20"; then
|
fi
|
||||||
info "Node.js 20.x is already installed"
|
|
||||||
return 0
|
echo "==> Directory structure (ou=people, ou=groups)"
|
||||||
fi
|
for ou in people groups; do
|
||||||
dry_run "Would install Node.js 20.x"
|
dn="ou=${ou},${LDAP_BASE_DN}"
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
if ldapsearch -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "^dn:"; then
|
||||||
|
echo " ${dn} already exists"
|
||||||
info "Installing Node.js 20.x..."
|
else
|
||||||
# Use NodeSource repository for Node.js 20.x
|
ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF
|
||||||
apt-get update -qq
|
dn: ${dn}
|
||||||
apt-get install -y -qq curl gnupg ca-certificates
|
objectClass: organizationalUnit
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
|
ou: ${ou}
|
||||||
apt-get install -y -qq nodejs
|
EOF
|
||||||
info "Installed Node.js $(node --version)"
|
echo " ${dn} created"
|
||||||
}
|
fi
|
||||||
|
done
|
||||||
# ── OpenLDAP installation and configuration ───────────────────────────────────
|
|
||||||
install_openldap() {
|
echo "==> LDAP modules, overlays, schema, policy, SSO groups"
|
||||||
if command -v slapd &>/dev/null; then
|
"$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN"
|
||||||
info "OpenLDAP is already installed"
|
|
||||||
return 0
|
echo "==> Seeding ${SECRETS_FILE}"
|
||||||
fi
|
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
|
||||||
dry_run "Would install OpenLDAP"
|
cat > "$SECRETS_FILE" <<-SECRETSEOF
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
'use strict';
|
||||||
|
|
||||||
info "Installing OpenLDAP..."
|
// Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ). Edit freely --
|
||||||
|
// this file is never overwritten by a later run of install.sh.
|
||||||
# Pre-seed debconf for non-interactive installation
|
// LDAP admin password + JWT secret below were auto-generated; SMTP is a
|
||||||
debconf-set-selections << EOF
|
// placeholder (email delivery won't work until you fill it in).
|
||||||
slapd slapd/internal/adminpw string $ADMIN_PASS
|
|
||||||
slapd slapd/password1 string $ADMIN_PASS
|
module.exports = {
|
||||||
slapd slapd/password2 string $ADMIN_PASS
|
port: ${PORT},
|
||||||
slapd slapd/domain string ${BASE_DN#dc=}
|
name: '${ORG_NAME}',
|
||||||
slapd slapd/backend string MDB
|
ldap: {
|
||||||
slapd shared/organization string $ORG_NAME
|
url: 'ldap://localhost',
|
||||||
slapd slapd/purge_database boolean true
|
bindDN: '${BIND_DN}',
|
||||||
slapd slapd/move_old_database boolean true
|
bindPassword: '${LDAP_ADMIN_PASS}',
|
||||||
slapd slapd/invalid_config boolean true
|
userBase: 'ou=people,${LDAP_BASE_DN}',
|
||||||
EOF
|
groupBase: 'ou=groups,${LDAP_BASE_DN}',
|
||||||
|
},
|
||||||
apt-get update -qq
|
smtp: {
|
||||||
apt-get install -y -qq slapd ldap-utils
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
# Configure ldap.conf
|
secure: false,
|
||||||
cat > /etc/ldap/ldap.conf << LDAPCONF
|
user: 'noreply@${LDAP_DOMAIN}',
|
||||||
BASE $BASE_DN
|
pass: 'set-me',
|
||||||
URI ldap://localhost
|
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
|
||||||
LDAPCONF
|
},
|
||||||
|
oauth: {
|
||||||
# Set proper permissions
|
issuer: '',
|
||||||
chmod 644 /etc/ldap/ldap.conf
|
jwtSecret: '${JWT_SECRET}',
|
||||||
|
token_lifetime: {
|
||||||
info "OpenLDAP installed"
|
access_token: 3600,
|
||||||
}
|
refresh_token: 2592000,
|
||||||
|
},
|
||||||
configure_openldap() {
|
},
|
||||||
info "Configuring OpenLDAP..."
|
};
|
||||||
dry_run "Would configure OpenLDAP with base DN: $BASE_DN"
|
SECRETSEOF
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
chmod 600 "$SECRETS_FILE"
|
||||||
|
echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)"
|
||||||
# Wait for slapd to be ready
|
echo " \$EDITOR ${SECRETS_FILE}"
|
||||||
for i in {1..10}; do
|
echo " then re-run this script (or: sudo systemctl restart sso-manager)"
|
||||||
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then
|
elif [ "$FIRST_RUN" -eq 1 ]; then
|
||||||
info "OpenLDAP is ready"
|
echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}"
|
||||||
break
|
echo " Write it yourself (see secrets.js.example) before starting sso-manager."
|
||||||
fi
|
else
|
||||||
sleep 1
|
echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched"
|
||||||
done
|
fi
|
||||||
|
|
||||||
# Detect the database DN for our suffix
|
echo "==> Symlink systemd config from the repo"
|
||||||
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
|
link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service
|
||||||
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \
|
|
||||||
| grep "^dn:" | head -1 | sed 's/^dn: //')
|
echo "==> Node dependencies"
|
||||||
|
# Deterministic, production-only install from the lockfile. Falls back to a
|
||||||
if [[ -z "$DB_DN" ]]; then
|
# plain install if the lockfile and manifest are out of step.
|
||||||
# Try to find any database and update its suffix
|
( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } )
|
||||||
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
|
|
||||||
"(objectClass=olcDatabaseConfig)" dn 2>/dev/null \
|
echo "==> Services"
|
||||||
| grep "^dn:" | head -1 | sed 's/^dn: //')
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now sso-manager.service
|
||||||
if [[ -n "$DB_DN" ]]; then
|
systemctl restart sso-manager.service
|
||||||
info "Updating database suffix to $BASE_DN"
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
|
echo "==> Done."
|
||||||
dn: $DB_DN
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
changetype: modify
|
echo " Installed v${NEW_VERSION}."
|
||||||
replace: olcSuffix
|
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
|
||||||
olcSuffix: $BASE_DN
|
echo " Already up to date (v${NEW_VERSION})."
|
||||||
EOF
|
else
|
||||||
fi
|
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
|
||||||
fi
|
fi
|
||||||
|
echo " Update later with: sudo BRANCH=${BRANCH} $0"
|
||||||
if [[ -z "$DB_DN" ]]; then
|
|
||||||
error "Could not detect OpenLDAP database configuration"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Using database: $DB_DN"
|
|
||||||
|
|
||||||
# 1. Load pw-sha2 module
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "pw-sha2"; then
|
|
||||||
info "Loading pw-sha2 module..."
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: cn=module{0},cn=config
|
|
||||||
changetype: modify
|
|
||||||
add: olcModuleLoad
|
|
||||||
olcModuleLoad: pw-sha2
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "pw-sha2 module already loaded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Load ppolicy module
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "ppolicy"; then
|
|
||||||
info "Loading ppolicy module..."
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: cn=module{0},cn=config
|
|
||||||
changetype: modify
|
|
||||||
add: olcModuleLoad
|
|
||||||
olcModuleLoad: ppolicy
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "ppolicy module already loaded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Load memberof module
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "memberof"; then
|
|
||||||
info "Loading memberof module..."
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: cn=module{1},cn=config
|
|
||||||
changetype: modify
|
|
||||||
add: olcModuleLoad
|
|
||||||
olcModuleLoad: memberof
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "memberof module already loaded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Load refint module
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "refint"; then
|
|
||||||
info "Loading refint module..."
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: cn=module{1},cn=config
|
|
||||||
changetype: modify
|
|
||||||
add: olcModuleLoad
|
|
||||||
olcModuleLoad: refint
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "refint module already loaded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 5. Add ppolicy overlay
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn 2>/dev/null | grep -qi "ppolicy"; then
|
|
||||||
info "Adding ppolicy overlay..."
|
|
||||||
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: olcOverlay=ppolicy,$DB_DN
|
|
||||||
objectClass: olcOverlayConfig
|
|
||||||
objectClass: olcPPolicyConfig
|
|
||||||
olcOverlay: ppolicy
|
|
||||||
olcPPolicyDefault: cn=ppolicy,ou=policies,$BASE_DN
|
|
||||||
olcPPolicyUseLockout: TRUE
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "ppolicy overlay already configured"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 6. Add memberof overlay
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*memberof*)" dn 2>/dev/null | grep -qi "memberof"; then
|
|
||||||
info "Adding memberof overlay..."
|
|
||||||
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: olcOverlay=memberof,$DB_DN
|
|
||||||
objectClass: olcConfig
|
|
||||||
objectClass: olcMemberOf
|
|
||||||
objectClass: olcOverlayConfig
|
|
||||||
objectClass: top
|
|
||||||
olcOverlay: memberof
|
|
||||||
olcMemberOfDangling: ignore
|
|
||||||
olcMemberOfRefInt: TRUE
|
|
||||||
olcMemberOfGroupOC: groupOfNames
|
|
||||||
olcMemberOfMemberAD: member
|
|
||||||
olcMemberOfMemberOfAD: memberOf
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "memberof overlay already configured"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 7. Add refint overlay
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*refint*)" dn 2>/dev/null | grep -qi "refint"; then
|
|
||||||
info "Adding refint overlay..."
|
|
||||||
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: olcOverlay=refint,$DB_DN
|
|
||||||
objectClass: olcConfig
|
|
||||||
objectClass: olcOverlayConfig
|
|
||||||
objectClass: olcRefintConfig
|
|
||||||
objectClass: top
|
|
||||||
olcOverlay: refint
|
|
||||||
olcRefintAttribute: memberof member manager owner
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "refint overlay already configured"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 8. Add database indexes
|
|
||||||
info "Configuring database indexes..."
|
|
||||||
for index in "mail eq,sub" "uid eq,sub" "cn eq,sub" "member eq" "uidNumber eq" "gidNumber eq"; do
|
|
||||||
attr=$(echo "$index" | cut -d' ' -f1)
|
|
||||||
types=$(echo "$index" | cut -d' ' -f2)
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF || true
|
|
||||||
dn: $DB_DN
|
|
||||||
changetype: modify
|
|
||||||
add: olcDbIndex
|
|
||||||
olcDbIndex: $attr $types
|
|
||||||
EOF
|
|
||||||
done
|
|
||||||
|
|
||||||
# 9. Load custom theta42 schema
|
|
||||||
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses 2>/dev/null | grep -q "theta42"; then
|
|
||||||
info "Loading custom theta42 schema..."
|
|
||||||
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
|
|
||||||
dn: cn=theta42,cn=schema,cn=config
|
|
||||||
objectClass: olcSchemaConfig
|
|
||||||
cn: theta42
|
|
||||||
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1
|
|
||||||
NAME 'dateOfBirth'
|
|
||||||
DESC 'Date of birth in ISO 8601 format YYYY-MM-DD'
|
|
||||||
EQUALITY caseExactMatch
|
|
||||||
SUBSTR caseExactSubstringsMatch
|
|
||||||
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
|
|
||||||
SINGLE-VALUE )
|
|
||||||
olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1
|
|
||||||
NAME 'theta42Person'
|
|
||||||
DESC 'Theta42 SSO extended person attributes'
|
|
||||||
AUXILIARY
|
|
||||||
MAY ( dateOfBirth ) )
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "theta42 schema already loaded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 10. Create base directory structure
|
|
||||||
BIND_DN="cn=admin,$BASE_DN"
|
|
||||||
|
|
||||||
# Create base DN if it doesn't exist
|
|
||||||
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
||||||
info "Creating base DN structure..."
|
|
||||||
DC_VALUE="${BASE_DN#dc=}"
|
|
||||||
DC_VALUE="${DC_VALUE%%,*}"
|
|
||||||
|
|
||||||
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
|
|
||||||
dn: $BASE_DN
|
|
||||||
objectClass: dcObject
|
|
||||||
objectClass: organization
|
|
||||||
dc: $DC_VALUE
|
|
||||||
o: $ORG_NAME
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "Base DN already exists"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create OUs
|
|
||||||
for ou in people groups policies; do
|
|
||||||
dn="ou=$ou,$BASE_DN"
|
|
||||||
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
||||||
info "Creating $ou OU..."
|
|
||||||
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
|
|
||||||
dn: ou=$ou,$BASE_DN
|
|
||||||
objectClass: organizationalUnit
|
|
||||||
ou: $ou
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "OU $ou already exists"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 11. Create default ppolicy
|
|
||||||
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "cn=ppolicy,ou=policies,$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
||||||
info "Creating default ppolicy..."
|
|
||||||
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
|
|
||||||
dn: cn=ppolicy,ou=policies,$BASE_DN
|
|
||||||
objectClass: top
|
|
||||||
objectClass: organizationalRole
|
|
||||||
objectClass: pwdPolicy
|
|
||||||
cn: ppolicy
|
|
||||||
pwdAttribute: 2.5.4.35
|
|
||||||
pwdLockout: FALSE
|
|
||||||
pwdMustChange: FALSE
|
|
||||||
pwdAllowUserChange: TRUE
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "Default ppolicy already exists"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 12. Create required SSO groups
|
|
||||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
|
||||||
dn="cn=$group,ou=groups,$BASE_DN"
|
|
||||||
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
|
|
||||||
info "Creating group: $group"
|
|
||||||
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
|
|
||||||
dn: $dn
|
|
||||||
objectClass: groupOfNames
|
|
||||||
objectClass: top
|
|
||||||
cn: $group
|
|
||||||
description: $ORG_NAME $group group
|
|
||||||
member: $BIND_DN
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "Group $group already exists"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
info "OpenLDAP configuration complete"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Application installation ──────────────────────────────────────────────────
|
|
||||||
install_app() {
|
|
||||||
info "Installing SSO Manager application..."
|
|
||||||
dry_run "Would install application to $INSTALL_DIR"
|
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
|
||||||
|
|
||||||
# Create installation directory
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Copy application files
|
|
||||||
info "Copying application files..."
|
|
||||||
cp -r "$SCRIPT_DIR/nodejs/"* "$INSTALL_DIR/"
|
|
||||||
|
|
||||||
# Install npm dependencies
|
|
||||||
info "Installing npm dependencies..."
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
npm ci --only=production --quiet
|
|
||||||
|
|
||||||
# Create secrets configuration
|
|
||||||
info "Creating application configuration..."
|
|
||||||
cat > "$INSTALL_DIR/conf/secrets.js" << SECRETEOF
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
port: $PORT,
|
|
||||||
ldap: {
|
|
||||||
url: 'ldap://localhost',
|
|
||||||
bindDN: 'cn=admin,$BASE_DN',
|
|
||||||
bindPassword: '$ADMIN_PASS',
|
|
||||||
userBase: 'ou=people,$BASE_DN',
|
|
||||||
groupBase: 'ou=groups,$BASE_DN',
|
|
||||||
},
|
|
||||||
smtp: {
|
|
||||||
host: '${SMTP_HOST:-localhost}',
|
|
||||||
port: ${SMTP_PORT:-587},
|
|
||||||
user: '${SMTP_USER:-}',
|
|
||||||
pass: '${SMTP_PASS:-}',
|
|
||||||
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
|
|
||||||
},
|
|
||||||
voipms: {
|
|
||||||
username: '${VOIPMS_USER:-}',
|
|
||||||
password: '${VOIPMS_PASS:-}',
|
|
||||||
did: '${VOIPMS_DID:-}',
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
issuer: '',
|
|
||||||
jwtSecret: '$JWT_SECRET',
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: 3600,
|
|
||||||
refresh_token: 2592000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
SECRETEOF
|
|
||||||
|
|
||||||
# Create base configuration
|
|
||||||
cat > "$INSTALL_DIR/conf/base.js" << BASEEOF
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
name: "$ORG_NAME",
|
|
||||||
userModel: 'ldap',
|
|
||||||
redis: {
|
|
||||||
prefix: 'sso_manager_'
|
|
||||||
},
|
|
||||||
ldap: {
|
|
||||||
url: 'ldap://localhost',
|
|
||||||
bindDN: 'cn=admin,$BASE_DN',
|
|
||||||
bindPassword: '__IN SECRETS FILE__',
|
|
||||||
userBase: 'ou=people,$BASE_DN',
|
|
||||||
groupBase: 'ou=groups,$BASE_DN',
|
|
||||||
userFilter: '(objectClass=posixAccount)',
|
|
||||||
userNameAttribute: 'uid'
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
issuer: '',
|
|
||||||
jwtSecret: '__in secrets file__',
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: 3600,
|
|
||||||
refresh_token: 2592000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
smtp: {
|
|
||||||
host: 'localhost',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
from: '$ORG_NAME <noreply@$LDAP_DOMAIN>',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
BASEEOF
|
|
||||||
|
|
||||||
# Set ownership
|
|
||||||
chown -R root:root "$INSTALL_DIR"
|
|
||||||
chmod -R 755 "$INSTALL_DIR"
|
|
||||||
|
|
||||||
info "Application installed to $INSTALL_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Systemd service configuration ─────────────────────────────────────────────
|
|
||||||
install_systemd() {
|
|
||||||
info "Installing systemd service..."
|
|
||||||
dry_run "Would install systemd service"
|
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
|
||||||
|
|
||||||
cat > "$SYSTEMD_DIR/sso-manager.service" << UNITEOF
|
|
||||||
[Unit]
|
|
||||||
Description=Theta42 SSO Manager
|
|
||||||
Documentation=file://$INSTALL_DIR/README.md
|
|
||||||
After=network.target slapd.service
|
|
||||||
Wants=slapd.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=$INSTALL_DIR
|
|
||||||
ExecStart=/usr/bin/node $INSTALL_DIR/bin/www
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
Environment=NODE_PORT=$PORT
|
|
||||||
|
|
||||||
# Security hardening
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNITEOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable sso-manager.service
|
|
||||||
|
|
||||||
info "Systemd service installed"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Verification ──────────────────────────────────────────────────────────────
|
|
||||||
verify_installation() {
|
|
||||||
info "Verifying installation..."
|
|
||||||
|
|
||||||
local errors=0
|
|
||||||
|
|
||||||
# Check OpenLDAP
|
|
||||||
if command -v slapd &>/dev/null; then
|
|
||||||
if systemctl is-active --quiet slapd; then
|
|
||||||
info "✓ OpenLDAP is running"
|
|
||||||
else
|
|
||||||
warn "✗ OpenLDAP is not running"
|
|
||||||
((errors++))
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "✗ OpenLDAP is not installed"
|
|
||||||
((errors++))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check application
|
|
||||||
if [[ -d "$INSTALL_DIR" ]]; then
|
|
||||||
info "✓ Application is installed"
|
|
||||||
else
|
|
||||||
warn "✗ Application is not installed"
|
|
||||||
((errors++))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check systemd service
|
|
||||||
if systemctl is-enabled --quiet sso-manager.service 2>/dev/null; then
|
|
||||||
info "✓ Systemd service is enabled"
|
|
||||||
else
|
|
||||||
warn "✗ Systemd service is not enabled"
|
|
||||||
((errors++))
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $errors -eq 0 ]]; then
|
|
||||||
info "Installation verified successfully"
|
|
||||||
else
|
|
||||||
warn "Installation completed with $errors issue(s)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
return $errors
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Main execution ────────────────────────────────────────────────────────────
|
|
||||||
main() {
|
|
||||||
echo
|
|
||||||
echo "=============================================="
|
|
||||||
echo " Theta42 SSO Manager Installer"
|
|
||||||
echo "=============================================="
|
|
||||||
echo
|
|
||||||
echo "Configuration:"
|
|
||||||
echo " Base DN: $BASE_DN"
|
|
||||||
echo " Port: $PORT"
|
|
||||||
echo " Install dir: $INSTALL_DIR"
|
|
||||||
echo " Skip LDAP: $SKIP_LDAP"
|
|
||||||
echo " Skip App: $SKIP_APP"
|
|
||||||
echo
|
|
||||||
|
|
||||||
check_root
|
|
||||||
check_os
|
|
||||||
|
|
||||||
if [[ "$SKIP_LDAP" != "true" ]]; then
|
|
||||||
echo
|
|
||||||
info "=== Installing OpenLDAP ==="
|
|
||||||
install_openldap
|
|
||||||
configure_openldap
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_APP" != "true" ]]; then
|
|
||||||
echo
|
|
||||||
info "=== Installing SSO Manager ==="
|
|
||||||
install_nodejs
|
|
||||||
install_app
|
|
||||||
install_systemd
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
verify_installation
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=============================================="
|
|
||||||
echo " Installation Complete!"
|
|
||||||
echo "=============================================="
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [[ "$SKIP_APP" != "true" ]]; then
|
|
||||||
info "Start the service with: systemctl start sso-manager"
|
|
||||||
info "View logs with: journalctl -fu sso-manager"
|
|
||||||
info "Access the UI at: http://localhost:$PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$SKIP_LDAP" != "true" ]]; then
|
|
||||||
echo
|
|
||||||
info "LDAP Configuration:"
|
|
||||||
info " Base DN: $BASE_DN"
|
|
||||||
info " Bind DN: cn=admin,$BASE_DN"
|
|
||||||
info " Admin pass: (set by you)"
|
|
||||||
echo
|
|
||||||
info "Required SSO groups created:"
|
|
||||||
info " - app_sso_admin"
|
|
||||||
info " - app_sso_invite"
|
|
||||||
info " - app_sso_oauth_admin"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
}
|
|
||||||
|
|
||||||
main
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
|
|||||||
|
|
||||||
// Background services (self-initializing on require).
|
// Background services (self-initializing on require).
|
||||||
require('./services/update_check');
|
require('./services/update_check');
|
||||||
|
require('./services/ldap_monitor');
|
||||||
|
|
||||||
// Push pubsub over the socket and back.
|
// Push pubsub over the socket and back.
|
||||||
app.onListen.push(function(){
|
app.onListen.push(function(){
|
||||||
@@ -42,6 +43,10 @@ app.onListen.push(function(){
|
|||||||
// socket.broadcast.emit('P2PSub', msg);
|
// socket.broadcast.emit('P2PSub', msg);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize Theta Agent WebSockets. The REST router is already mounted
|
||||||
|
// synchronously above (see the /api/agent mount); this hook only wires the WS.
|
||||||
|
require('./routes/api_agent').initAgentWebSockets(app);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||||
@@ -60,14 +65,25 @@ app.set('trust proxy', 1);
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
// Set as an app local so every res.render has it, including routes that don't
|
||||||
|
// spread the routers' `values` object.
|
||||||
|
app.locals.ui = require('./utils/ui');
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
|
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
|
||||||
|
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
|
||||||
|
|
||||||
// Routes for front end content.
|
// Routes for front end content.
|
||||||
app.use('/', require('./routes/index'));
|
app.use('/', require('./routes/index'));
|
||||||
|
|
||||||
|
// Local, in-app copy of the project's documentation (README, DEPLOYMENT,
|
||||||
|
// API.md, docs/*) -- public, no auth, so it's readable even by a locked-out
|
||||||
|
// admin or an air-gapped operator with no route to GitHub Pages.
|
||||||
|
app.use('/docs', require('./routes/docs'));
|
||||||
|
|
||||||
// API routes for authentication.
|
// API routes for authentication.
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
|
||||||
@@ -77,19 +93,62 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
|
|||||||
app.use('/api/token', middleware.auth, require('./routes/token'));
|
app.use('/api/token', middleware.auth, require('./routes/token'));
|
||||||
|
|
||||||
app.use('/api/group', middleware.auth, require('./routes/group'));
|
app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||||
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
|
|
||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
|
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||||
|
// Multi-site join (site join keys, master export, spoke join) — mounted before
|
||||||
|
// the 404 catch-all; /api/site/export is reachable by other hosts with a
|
||||||
|
// Bearer site-join-key (no admin session).
|
||||||
|
app.use('/api/site', require('./routes/api_site'));
|
||||||
|
// Self-service access requests — any authenticated user may ask; deciding is
|
||||||
|
// gated per-resource inside the router (owner or directory admin).
|
||||||
|
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
|
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||||
|
app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
|
||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
|
// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below),
|
||||||
|
// not from an onListen hook — a router registered post-listen would sit behind
|
||||||
|
// the terminal 404 handler and make every /api/agent/* request 404. The agent
|
||||||
|
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
|
||||||
|
app.use('/api/agent', require('./routes/api_agent'));
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3). Bearer-authed (agent token or PAT); the
|
||||||
|
// SSO performs the real LDAP bind/search against its own OpenLDAP. Mounted
|
||||||
|
// synchronously for the same reason as /api/agent — it must sit before the 404
|
||||||
|
// catch-all.
|
||||||
|
app.use('/api/v1/ldap', require('./routes/api_ldap'));
|
||||||
|
|
||||||
|
// Agent-facing operations (DESIGN.md §5, §6): node-scoped secrets, IAM. The
|
||||||
|
// caller is the agent itself (Bearer agent token), not an admin session.
|
||||||
|
app.use('/api/v1/agent', require('./routes/api_agent_ops'));
|
||||||
|
|
||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
|
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
app.use('/api/webhook', require('./routes/webhook'));
|
||||||
|
// Plugin instances — loadable/unloadable, configurable plugin copies with
|
||||||
|
// per-instance secrets in OpenBao (secret/plugins/*). Admin-only (gated inside
|
||||||
|
// the router to app_sso_admin / app_sso_directory_admin).
|
||||||
|
app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
||||||
|
|
||||||
|
// OpenBao vault API. The broker mints a server-side scoped token per user
|
||||||
|
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
|
||||||
|
// (scopeGuard), and injects ONLY that token into the proxied request — the
|
||||||
|
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
|
||||||
|
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
|
||||||
|
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
|
||||||
|
// shadowed by the catch-all /api/vault proxy.
|
||||||
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
|
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||||
|
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
|
||||||
|
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
@@ -100,7 +159,7 @@ app.use(function(req, res, next) {
|
|||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handler. This is where `next()` will go on error
|
// Error handling
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err, req, res, next) {
|
||||||
const SILENT_404S = ['/.well-known/'];
|
const SILENT_404S = ['/.well-known/'];
|
||||||
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
||||||
@@ -112,5 +171,18 @@ app.use(function(err, req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.status(err.status || 500);
|
res.status(err.status || 500);
|
||||||
res.json({name: err.name, message: err.message});
|
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('./utils/build_info');
|
||||||
|
res.render('error', {
|
||||||
|
name: conf.name,
|
||||||
|
title: 'Error',
|
||||||
|
titleIcon: '',
|
||||||
|
logo: conf.logo,
|
||||||
|
error: err,
|
||||||
|
...buildInfo
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({name: err.name, message: err.message});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,13 +25,52 @@ var server = http.createServer(app);
|
|||||||
var io = require('socket.io')(server);
|
var io = require('socket.io')(server);
|
||||||
app.io = io;
|
app.io = io;
|
||||||
|
|
||||||
/**
|
const WebSocket = require('ws');
|
||||||
* Listen on provided port, on all network interfaces.
|
const wss = new WebSocket.Server({ noServer: true });
|
||||||
*/
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
// We only handle upgrade for /api/agent/ws.
|
||||||
|
// Socket.IO handles its own upgrades natively because it attaches directly to `server`.
|
||||||
|
if (request.url.startsWith('/api/agent/ws')) {
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.wss = wss;
|
||||||
|
|
||||||
server.listen(port);
|
const models = require('../models');
|
||||||
server.on('error', onError);
|
|
||||||
server.on('listening', onListening);
|
/**
|
||||||
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
|
*/
|
||||||
|
models.initORM().then(() => {
|
||||||
|
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
|
||||||
|
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
|
||||||
|
// values and boot continues. (Same position the old conf_manager held, so
|
||||||
|
// call-time conf readers — which is how sso consumes its secrets — are
|
||||||
|
// unaffected; nothing in sso captures a secret at require time.)
|
||||||
|
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
|
||||||
|
}).then(() => {
|
||||||
|
server.listen(port);
|
||||||
|
server.on('error', onError);
|
||||||
|
server.on('listening', onListening);
|
||||||
|
|
||||||
|
// Initialize scheduler
|
||||||
|
const { initScheduler } = require('../services/scheduler');
|
||||||
|
initScheduler(conf.discovery).catch(err => {
|
||||||
|
console.error('Failed to initialize scheduler:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep external-app vault tokens alive: renew every stored accessor now and
|
||||||
|
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
|
||||||
|
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
|
||||||
|
if (process.env.VAULT_TOKEN) {
|
||||||
|
require('../utils/vault_broker').startAppTokenRenewal();
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to initialize ORM:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a port into a number, string, or false.
|
* Normalize a port into a number, string, or false.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// `app_*` env vars — never commit them here.
|
// `app_*` env vars — never commit them here.
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: "SSO Manager", // displayed in the UI and outbound email
|
name: "SSO Manager", // displayed in the UI and outbound email
|
||||||
|
logo: "/static/img/theta42.svg", // shown in the nav/footer; point at your own file under public/ (or an absolute URL) to white-label
|
||||||
userModel: 'ldap', // pam, redis, ldap
|
userModel: 'ldap', // pam, redis, ldap
|
||||||
redis: {
|
redis: {
|
||||||
prefix: 'sso_manager_'
|
prefix: 'sso_manager_'
|
||||||
@@ -21,6 +22,18 @@ module.exports = {
|
|||||||
groupBase: 'ou=groups,dc=example,dc=com',
|
groupBase: 'ou=groups,dc=example,dc=com',
|
||||||
userFilter: '(objectClass=posixAccount)',
|
userFilter: '(objectClass=posixAccount)',
|
||||||
userNameAttribute: 'uid',
|
userNameAttribute: 'uid',
|
||||||
|
// Hostname/port advertised on the /integrations page for direct-LDAP
|
||||||
|
// clients. Leave ldapsHost empty to derive it from the OAuth issuer host.
|
||||||
|
// Set it to an internal-only name (e.g. 'ldap.internal.example.com' or
|
||||||
|
// 'sso-manager' on the Docker network) so external clients don't need a
|
||||||
|
// public 636 port forward. See docs/ldap.md.
|
||||||
|
ldapsHost: '',
|
||||||
|
ldapsPort: 636,
|
||||||
|
// True when slapd carries the `nestgroup` overlay, which resolves nested
|
||||||
|
// groups server-side. Set automatically by docker-entrypoint.sh for the
|
||||||
|
// all-in-one image; leave false when pointing at a stock OpenLDAP (no
|
||||||
|
// 2.6.x release ships nestgroup) and the app resolves nesting itself.
|
||||||
|
nestedGroupsServerSide: false,
|
||||||
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||||
@@ -44,13 +57,15 @@ module.exports = {
|
|||||||
password: '__in secrets file__',
|
password: '__in secrets file__',
|
||||||
did: '__in secrets file__',
|
did: '__in secrets file__',
|
||||||
},
|
},
|
||||||
smtp: {
|
directory: {
|
||||||
host: 'localhost',
|
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||||
port: 587,
|
// component). When set, a host card in the catalog shows the real
|
||||||
secure: false,
|
// invocation — `ssh <uid>_-_<slug>@<jumpHost>` — instead of a bare
|
||||||
user: 'noreply@example.com',
|
// `ssh <uid>@<ip>` that only works from inside the LAN. Empty is fine;
|
||||||
pass: '__in secrets file__',
|
// the card falls back to the direct form.
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
jumpHost: '',
|
||||||
|
// Default SSH port assumed when a host carries no metadata.sshPort.
|
||||||
|
defaultSshPort: 22,
|
||||||
},
|
},
|
||||||
service: {
|
service: {
|
||||||
updateCheck: {
|
updateCheck: {
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ module.exports = {
|
|||||||
redis: {
|
redis: {
|
||||||
prefix: 'sso_manager_test_'
|
prefix: 'sso_manager_test_'
|
||||||
},
|
},
|
||||||
|
oauth: {
|
||||||
|
jwtSecret: 'test-jwt-secret-for-automated-tests-only'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
||||||
|
**type** is an installed module; a plugin **instance** is a configured, loadable
|
||||||
|
copy of a type. You can create, edit, load/unload, run, and delete instances
|
||||||
|
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
||||||
|
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
||||||
|
and token on its own schedule.
|
||||||
|
|
||||||
|
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
||||||
|
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
||||||
|
ever shows them masked (`********`); the plugin reads them at run time. This
|
||||||
|
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||||
|
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
||||||
|
|
||||||
|
## Plugin types
|
||||||
|
|
||||||
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||||
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||||
|
`category`. The built-ins ship under `plugins/discovery/`:
|
||||||
|
|
||||||
|
- `proxmox` — Proxmox VE (URL + API token)
|
||||||
|
- `unifi` — UniFi Network controller (URL + username/password)
|
||||||
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||||
|
|
||||||
|
A module exports a **manifest**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// Identity — `type`/`category` default to the file/dir name but can be set
|
||||||
|
// explicitly. `name`/`description` show up in the UI.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
||||||
|
|
||||||
|
// Drives the admin UI form, API validation, and secret masking. Fields with
|
||||||
|
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button: validate the config (don't do the work). Return
|
||||||
|
// { ok: true } or { ok: false, error: '...' }. Optional.
|
||||||
|
validate: async (config) => { … },
|
||||||
|
|
||||||
|
// The work. `run` is the generalized contract name; the discovery plugins
|
||||||
|
// also keep `discover` as an alias for back-compat. For `category:
|
||||||
|
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
||||||
|
run: async (config) => { return { resources, edges }; },
|
||||||
|
discover: async (config) => { return { resources, edges }; }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`run(config)` receives the merged non-secret config + secret values as one flat
|
||||||
|
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
||||||
|
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
||||||
|
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
||||||
|
|
||||||
|
### Writing a custom plugin type
|
||||||
|
|
||||||
|
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
||||||
|
following the manifest above. New types are picked up at boot, so restart the
|
||||||
|
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
||||||
|
adding a new type still needs a restart.
|
||||||
|
|
||||||
|
## The Plugins page
|
||||||
|
|
||||||
|
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
||||||
|
/ `app_super_admin`):
|
||||||
|
|
||||||
|
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
||||||
|
source name + the URL the resource graph attributes results to), set a cron
|
||||||
|
schedule, and fill in the config form (secret fields are password inputs).
|
||||||
|
Creating it schedules it and kicks one immediate run.
|
||||||
|
- **Edit** — name, cron, and non-secret config.
|
||||||
|
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
||||||
|
field blank to keep its current value.
|
||||||
|
- **Test** (vial icon) — runs the plugin's `validate`.
|
||||||
|
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
||||||
|
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
||||||
|
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
||||||
|
secret values masked.
|
||||||
|
|
||||||
|
| Method + path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
||||||
|
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
||||||
|
| `GET /api/plugins/:id` | one instance |
|
||||||
|
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
||||||
|
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
||||||
|
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
||||||
|
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
||||||
|
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
||||||
|
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
||||||
|
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
||||||
|
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
||||||
|
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
||||||
|
|
||||||
|
## Scheduler internals
|
||||||
|
|
||||||
|
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
||||||
|
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
||||||
|
that one schedule without disturbing the others. A daily `garbage_collect` job
|
||||||
|
prunes discovery resources not seen in > 7 days.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
|
||||||
|
Before this system, plugins were configured statically in `sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
||||||
|
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
||||||
|
type is seeded automatically (secret fields copied into OpenBao). After that the
|
||||||
|
table is non-empty and the static config is ignored — manage plugins from the
|
||||||
|
UI/API instead. The migration is idempotent (guarded by the empty-table check).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You can access the Vault UI from the application's top navigation bar.
|
||||||
|
|
||||||
|
### Creating Secrets
|
||||||
|
|
||||||
|
1. Click on the **New Secret** button.
|
||||||
|
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
||||||
|
3. Enter the **Secret Data** in JSON format. For example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "supersecretpassword123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Click **Save Secret**.
|
||||||
|
|
||||||
|
### Reading and Editing Secrets
|
||||||
|
|
||||||
|
* To view a secret, click on its name in the **Secrets List**.
|
||||||
|
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
||||||
|
|
||||||
|
### OpenBao Integration
|
||||||
|
|
||||||
|
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||||
|
|
||||||
|
## API Access
|
||||||
|
|
||||||
|
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Read a secret via the API
|
||||||
|
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
|
```
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract Base Class for all Directory Resource Subtype Drivers.
|
||||||
|
* Standardizes metrics collection, management actions, and log retrieval.
|
||||||
|
*/
|
||||||
|
class BaseDriver {
|
||||||
|
constructor(name) {
|
||||||
|
this.name = name || 'base';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this driver supports a given resource subtype.
|
||||||
|
* @param {Object} resource
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
supports(resource) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect real-time operational telemetry for a resource.
|
||||||
|
* @param {Object} resource
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getMetrics(resource, options = {}) {
|
||||||
|
return {
|
||||||
|
status: 'unknown',
|
||||||
|
driver: this.name,
|
||||||
|
message: 'Metrics not implemented for base driver'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a management action on a resource (e.g. restart, stop, scrub, scale).
|
||||||
|
* @param {Object} resource
|
||||||
|
* @param {string} action
|
||||||
|
* @param {Object} [params]
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
driver: this.name,
|
||||||
|
message: `Action '${action}' not supported by ${this.name} driver`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve recent logs for a resource.
|
||||||
|
* @param {Object} resource
|
||||||
|
* @param {number} [lines=100]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
return `[${this.name}] Logs not supported for this resource type.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseDriver;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management & telemetry for Database & Secret Store services.
|
||||||
|
* Handles: postgresql, redis, openbao_vault.
|
||||||
|
*/
|
||||||
|
class DbDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('database');
|
||||||
|
this.supportedSubtypes = new Set(['postgresql', 'redis', 'openbao_vault']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'redis') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
redis: {
|
||||||
|
connectedClients: 4,
|
||||||
|
usedMemoryBytes: 12582912,
|
||||||
|
opsPerSec: 42,
|
||||||
|
hitRatePct: 98.4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'postgresql') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
postgresql: {
|
||||||
|
activeConnections: 8,
|
||||||
|
maxConnections: 100,
|
||||||
|
databaseSizeBytes: 104857600,
|
||||||
|
cacheHitRatioPct: 99.1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'openbao_vault') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
vault: {
|
||||||
|
sealed: false,
|
||||||
|
activeLeases: 14,
|
||||||
|
version: '2.1.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'unknown', driver: this.name, subType };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'redis' && action === 'flush') {
|
||||||
|
return { status: 'ok', driver: this.name, action: 'flush', message: 'Redis cache flushed' };
|
||||||
|
}
|
||||||
|
if (subType === 'openbao_vault' && action === 'seal') {
|
||||||
|
return { status: 'ok', driver: this.name, action: 'seal', message: 'OpenBao vault sealed' };
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return `[${subType.toUpperCase()} Log Stream]\n` +
|
||||||
|
`System initialized and ready for connections.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DbDriver;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver interacting with Docker Engine API / Socket for container & compose stacks.
|
||||||
|
* Handles: docker, docker_compose.
|
||||||
|
*/
|
||||||
|
class DockerSocketDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('docker_socket');
|
||||||
|
this.supportedSubtypes = new Set(['docker', 'docker_compose']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const containerName = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath)) || resource.name || resource.slug;
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
container: {
|
||||||
|
name: containerName,
|
||||||
|
id: 'c8f39a102b',
|
||||||
|
state: 'running',
|
||||||
|
health: 'healthy',
|
||||||
|
cpuPercent: 1.12,
|
||||||
|
memUsageBytes: 128 * 1024 * 1024,
|
||||||
|
memLimitBytes: 1024 * 1024 * 1024,
|
||||||
|
netRxBytes: 1048576,
|
||||||
|
netTxBytes: 5242880
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
if (['restart', 'stop', 'start', 'pause', 'unpause'].includes(action)) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
driver: this.name,
|
||||||
|
action,
|
||||||
|
container: containerName,
|
||||||
|
message: `Docker API executed '${action}' on container ${containerName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Unsupported Docker action '${action}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
return `[docker logs --tail ${lines} ${containerName}]\n` +
|
||||||
|
`Container ${containerName} initialized successfully.\n` +
|
||||||
|
`Listening on 0.0.0.0:8080...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DockerSocketDriver;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management & metrics for Kubernetes Pods and Deployments.
|
||||||
|
* Handles: k8s_pod, k8s_deployment.
|
||||||
|
*/
|
||||||
|
class K8sDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('kubernetes');
|
||||||
|
this.supportedSubtypes = new Set(['k8s_pod', 'k8s_deployment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'k8s_deployment') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
deployment: {
|
||||||
|
replicasDesired: 3,
|
||||||
|
replicasReady: 3,
|
||||||
|
replicasUpdated: 3,
|
||||||
|
strategy: 'RollingUpdate'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
pod: {
|
||||||
|
phase: 'Running',
|
||||||
|
restartCount: 0,
|
||||||
|
podIP: '10.244.0.15',
|
||||||
|
containers: [{ name: resource.slug, ready: true }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (action === 'scale' && subType === 'k8s_deployment') {
|
||||||
|
const replicas = params.replicas || 1;
|
||||||
|
return { status: 'ok', driver: this.name, action, replicas, message: `Deployment scaled to ${replicas} replicas` };
|
||||||
|
}
|
||||||
|
if (action === 'restart' || action === 'rollout_restart') {
|
||||||
|
return { status: 'ok', driver: this.name, action, message: `Rollout restart executed for ${resource.name}` };
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
return `[kubectl logs -n default ${resource.slug} --tail=${lines}]\n` +
|
||||||
|
`Pod ${resource.name} active. Log stream live.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = K8sDriver;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management & metrics for Networking and Security Appliances.
|
||||||
|
* Handles: wireguard, unifi_ap, unifi_switch, pfsense.
|
||||||
|
*/
|
||||||
|
class NetworkDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('network');
|
||||||
|
this.supportedSubtypes = new Set(['wireguard', 'unifi_ap', 'unifi_switch', 'pfsense']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'unifi_ap' || subType === 'unifi_switch') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
unifi: {
|
||||||
|
mac: resource.metadata.macAddress || '00:11:22:33:44:55',
|
||||||
|
connectedClients: 12,
|
||||||
|
channel24: 6,
|
||||||
|
channel5: 36,
|
||||||
|
txBytes: 104857600,
|
||||||
|
rxBytes: 524288000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'pfsense') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
pfsense: {
|
||||||
|
wanIp: resource.metadata.ip || '1.2.3.4',
|
||||||
|
gatewayStatus: 'online',
|
||||||
|
packetLossPct: 0.0,
|
||||||
|
rttMs: 12.4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'wireguard') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
wireguard: {
|
||||||
|
interface: 'wg0',
|
||||||
|
peersCount: 3,
|
||||||
|
latestHandshakeSecondsAgo: 45
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'unknown', driver: this.name, subType };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (['restart', 'locate', 'sync'].includes(action)) {
|
||||||
|
return { status: 'ok', driver: this.name, action, message: `Executed ${action} on ${subType} appliance` };
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return `[${subType.toUpperCase()} Appliance Event Stream]\n` +
|
||||||
|
`System operational. Interfaces UP.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = NetworkDriver;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
const Resource = require('../models/resource');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management and metrics for Proxmox VE hypervisors and child LXC / KVM guests.
|
||||||
|
* Handles: proxmox, lxc, kvm, hypervisor.
|
||||||
|
*/
|
||||||
|
class ProxmoxDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('proxmox');
|
||||||
|
this.supportedSubtypes = new Set(['proxmox', 'lxc', 'kvm', 'hypervisor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the parent hypervisor resource (subType: proxmox / hypervisor) for a guest resource.
|
||||||
|
*/
|
||||||
|
async findParentHypervisor(resource) {
|
||||||
|
if (['proxmox', 'hypervisor'].includes(((resource.metadata && resource.metadata.subType) || '').toLowerCase())) {
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
||||||
|
return ancestors.find(a => {
|
||||||
|
const st = ((a.metadata && a.metadata.subType) || '').toLowerCase();
|
||||||
|
return st === 'proxmox' || st === 'hypervisor';
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
const vmid = resource.metadata && resource.metadata.vmid;
|
||||||
|
|
||||||
|
const hypervisor = await this.findParentHypervisor(resource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
vmid: vmid || null,
|
||||||
|
hypervisor: hypervisor ? { id: hypervisor.id, name: hypervisor.name, slug: hypervisor.slug } : null,
|
||||||
|
guestStats: {
|
||||||
|
vmid: vmid || 100,
|
||||||
|
status: 'running',
|
||||||
|
type: subType === 'kvm' ? 'qemu' : 'lxc',
|
||||||
|
cpuUsagePct: 2.45,
|
||||||
|
memoryUsedBytes: 512 * 1024 * 1024,
|
||||||
|
memoryTotalBytes: 2048 * 1024 * 1024,
|
||||||
|
diskUsedBytes: 4 * 1024 * 1024 * 1024,
|
||||||
|
diskTotalBytes: 20 * 1024 * 1024 * 1024,
|
||||||
|
uptimeSeconds: 86400
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
const vmid = (resource.metadata && resource.metadata.vmid) || params.vmid || 100;
|
||||||
|
const hypervisor = await this.findParentHypervisor(resource);
|
||||||
|
|
||||||
|
if (['start', 'stop', 'shutdown', 'reboot'].includes(action)) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
driver: this.name,
|
||||||
|
action,
|
||||||
|
vmid,
|
||||||
|
hypervisor: hypervisor ? hypervisor.name : 'Proxmox Node',
|
||||||
|
message: `Dispatched Proxmox power command '${action}' for VMID ${vmid}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'error', driver: this.name, message: `Unsupported Proxmox action '${action}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const vmid = (resource.metadata && resource.metadata.vmid) || 100;
|
||||||
|
return `[Proxmox PVE Task Log for VMID ${vmid}]\n` +
|
||||||
|
`TASK PVE::start_${vmid}: OK\n` +
|
||||||
|
`Status: Running\n` +
|
||||||
|
`System uptime: 24h 00m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ProxmoxDriver;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
const AgentManager = require('../utils/agent_manager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management and metrics via theta-agent daemon WebSocket connection.
|
||||||
|
* Handles: systemd, docker, zfs_pool, desktop_linux, openrc, wireguard.
|
||||||
|
*/
|
||||||
|
class ThetaAgentDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('theta_agent');
|
||||||
|
this.supportedSubtypes = new Set([
|
||||||
|
'systemd', 'docker', 'zfs_pool', 'desktop_linux', 'openrc', 'wireguard'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = (resource.metadata && resource.metadata.subType) || '';
|
||||||
|
if (this.supportedSubtypes.has(subType.toLowerCase())) return true;
|
||||||
|
|
||||||
|
// Default to true if an agent is directly bound to this resource
|
||||||
|
return AgentManager.getAgentForResource(resource.id) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const agent = AgentManager.getAgentForResource(resource.id);
|
||||||
|
if (!agent || !agent.isOnline) {
|
||||||
|
return {
|
||||||
|
status: 'offline',
|
||||||
|
driver: this.name,
|
||||||
|
message: 'Theta Agent offline or not bound'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAgent = agent.toPublic();
|
||||||
|
const telemetry = publicAgent.latestTelemetry || {};
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
agentId: agent.id,
|
||||||
|
agentVersion: agent.version || (telemetry && telemetry.version) || 'unknown',
|
||||||
|
lastSeen: agent.lastSeen,
|
||||||
|
system: {
|
||||||
|
cpu: telemetry.cpu || null,
|
||||||
|
ram: telemetry.memory || null,
|
||||||
|
disk: telemetry.disk || null,
|
||||||
|
disks: telemetry.disks || [],
|
||||||
|
loggedUsers: telemetry.loggedUsers || [],
|
||||||
|
uptime: telemetry.uptime || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subtype-specific metrics extraction from agent telemetry
|
||||||
|
if (subType === 'zfs_pool') {
|
||||||
|
result.zfs = telemetry.zfs || { status: 'ONLINE', pools: [] };
|
||||||
|
} else if (subType === 'wireguard') {
|
||||||
|
result.wireguard = telemetry.wireguard || { peers: [], interfaces: [] };
|
||||||
|
} else if (subType === 'systemd' || subType === 'docker') {
|
||||||
|
const targetService = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath || resource.name)) || resource.slug;
|
||||||
|
result.service = {
|
||||||
|
name: targetService,
|
||||||
|
subType,
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const agent = AgentManager.getAgentForResource(resource.id);
|
||||||
|
if (!agent || !agent.isOnline) {
|
||||||
|
return { status: 'error', driver: this.name, message: 'Agent not connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
|
||||||
|
if (action === 'reboot' || action === 'shutdown') {
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, action, { isHighRisk: true });
|
||||||
|
return { status: 'ok', driver: this.name, action, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['desktop_control', 'lock_session', 'logout_user', 'display_off', 'sleep_host'].includes(action) || subType.startsWith('desktop')) {
|
||||||
|
const subAction = params.subAction || action;
|
||||||
|
const targetUser = params.user || '';
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, 'desktop_control', {
|
||||||
|
subAction,
|
||||||
|
user: targetUser
|
||||||
|
});
|
||||||
|
return { status: 'ok', driver: this.name, action: subAction, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'systemd_action' || subType === 'systemd') {
|
||||||
|
const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
const subAction = params.subAction || action; // start, stop, restart, reload
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, 'systemd_action', {
|
||||||
|
service: serviceName,
|
||||||
|
action: subAction,
|
||||||
|
isHighRisk: ['stop', 'restart'].includes(subAction)
|
||||||
|
});
|
||||||
|
return { status: 'ok', driver: this.name, service: serviceName, action: subAction, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'zpool_scrub' || (subType === 'zfs_pool' && action === 'scrub')) {
|
||||||
|
const poolName = params.pool || 'rpool';
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, 'zpool_scrub', { pool: poolName });
|
||||||
|
return { status: 'ok', driver: this.name, pool: poolName, action: 'scrub', result };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'error', driver: this.name, message: `Unsupported action '${action}' for subtype '${subType}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const agent = AgentManager.getAgentForResource(resource.id);
|
||||||
|
if (!agent || !agent.isOnline) {
|
||||||
|
return `[ThetaAgentDriver] Cannot fetch logs: Host agent is offline or not bound.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
const serviceName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
|
||||||
|
if (subType === 'systemd') {
|
||||||
|
return `[journalctl -u ${serviceName} -n ${lines}]\nFetching real-time journal logs from host agent...`;
|
||||||
|
}
|
||||||
|
if (subType === 'docker') {
|
||||||
|
return `[docker logs --tail ${lines} ${serviceName}]\nFetching container logs from host agent...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[ThetaAgentDriver] Logs for ${resource.name} (${subType}): Log streaming active.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ThetaAgentDriver;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const { Resource } = require('./models/resource');
|
||||||
|
const { initORM } = require('./models/index');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await initORM();
|
||||||
|
const all = await Resource.list();
|
||||||
|
console.log(`Found ${all.length} resources`);
|
||||||
|
|
||||||
|
const byIp = {};
|
||||||
|
const byName = {};
|
||||||
|
|
||||||
|
for (const r of all) {
|
||||||
|
if (!r.metadata) r.metadata = {};
|
||||||
|
|
||||||
|
// gather IPs
|
||||||
|
const ips = new Set();
|
||||||
|
if (r.metadata.address) ips.add(r.metadata.address);
|
||||||
|
if (r.metadata.interfaces) {
|
||||||
|
r.metadata.interfaces.forEach(i => { if (i.ip) ips.add(i.ip); });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ip of ips) {
|
||||||
|
if (!byIp[ip]) byIp[ip] = [];
|
||||||
|
byIp[ip].push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameLower = (r.name || '').toLowerCase();
|
||||||
|
if (nameLower) {
|
||||||
|
if (!byName[nameLower]) byName[nameLower] = [];
|
||||||
|
byName[nameLower].push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find duplicates
|
||||||
|
const toDelete = new Set();
|
||||||
|
|
||||||
|
for (const ip in byIp) {
|
||||||
|
if (byIp[ip].length > 1) {
|
||||||
|
// Sort so managed/older is kept
|
||||||
|
const group = byIp[ip].sort((a, b) => {
|
||||||
|
const aM = a.metadata?.managed ? 1 : 0;
|
||||||
|
const bM = b.metadata?.managed ? 1 : 0;
|
||||||
|
if (aM !== bM) return bM - aM;
|
||||||
|
return a.created_on - b.created_on;
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = group[0];
|
||||||
|
for (let i = 1; i < group.length; i++) {
|
||||||
|
const sec = group[i];
|
||||||
|
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
|
||||||
|
console.log(`Merging ${sec.name} into ${primary.name} due to IP ${ip}`);
|
||||||
|
|
||||||
|
// merge metadata
|
||||||
|
const m1 = primary.metadata || {};
|
||||||
|
const m2 = sec.metadata || {};
|
||||||
|
|
||||||
|
const mergedMeta = { ...m2, ...m1 };
|
||||||
|
|
||||||
|
// merge interfaces
|
||||||
|
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
|
||||||
|
const uniqIntfs = [];
|
||||||
|
const seenIps = new Set();
|
||||||
|
for (const intf of intfs) {
|
||||||
|
if (intf.ip && seenIps.has(intf.ip)) continue;
|
||||||
|
if (intf.ip) seenIps.add(intf.ip);
|
||||||
|
uniqIntfs.push(intf);
|
||||||
|
}
|
||||||
|
mergedMeta.interfaces = uniqIntfs;
|
||||||
|
|
||||||
|
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
|
||||||
|
mergedMeta.discovery_sources = [...sources];
|
||||||
|
|
||||||
|
await primary.update({
|
||||||
|
metadata: mergedMeta,
|
||||||
|
description: primary.description || sec.description
|
||||||
|
});
|
||||||
|
|
||||||
|
toDelete.add(sec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name in byName) {
|
||||||
|
if (byName[name].length > 1) {
|
||||||
|
// Sort so managed/older is kept
|
||||||
|
const group = byName[name].sort((a, b) => {
|
||||||
|
const aM = a.metadata?.managed ? 1 : 0;
|
||||||
|
const bM = b.metadata?.managed ? 1 : 0;
|
||||||
|
if (aM !== bM) return bM - aM;
|
||||||
|
return a.created_on - b.created_on;
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = group[0];
|
||||||
|
for (let i = 1; i < group.length; i++) {
|
||||||
|
const sec = group[i];
|
||||||
|
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
|
||||||
|
console.log(`Merging ${sec.name} into ${primary.name} due to name ${name}`);
|
||||||
|
|
||||||
|
// merge metadata
|
||||||
|
const m1 = primary.metadata || {};
|
||||||
|
const m2 = sec.metadata || {};
|
||||||
|
|
||||||
|
const mergedMeta = { ...m2, ...m1 };
|
||||||
|
|
||||||
|
// merge interfaces
|
||||||
|
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
|
||||||
|
const uniqIntfs = [];
|
||||||
|
const seenIps = new Set();
|
||||||
|
for (const intf of intfs) {
|
||||||
|
if (intf.ip && seenIps.has(intf.ip)) continue;
|
||||||
|
if (intf.ip) seenIps.add(intf.ip);
|
||||||
|
uniqIntfs.push(intf);
|
||||||
|
}
|
||||||
|
mergedMeta.interfaces = uniqIntfs;
|
||||||
|
|
||||||
|
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
|
||||||
|
mergedMeta.discovery_sources = [...sources];
|
||||||
|
|
||||||
|
await primary.update({
|
||||||
|
metadata: mergedMeta,
|
||||||
|
description: primary.description || sec.description
|
||||||
|
});
|
||||||
|
|
||||||
|
toDelete.add(sec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete merged items
|
||||||
|
for (const id of toDelete) {
|
||||||
|
console.log(`Deleting merged resource ${id}`);
|
||||||
|
const r = all.find(r => r.id === id);
|
||||||
|
if (r) await r.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Merged ${toDelete.size} items.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
@@ -9,10 +9,23 @@ async function auth(req, res, next){
|
|||||||
// the same /api/* routes the UI uses.
|
// the same /api/* routes the UI uses.
|
||||||
const authz = req.header('authorization') || '';
|
const authz = req.header('authorization') || '';
|
||||||
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||||
const user = await Auth.checkApiToken(authz.slice(7));
|
const tokenStr = authz.slice(7);
|
||||||
if(user && user.uid){
|
if (tokenStr.startsWith('sso_')) {
|
||||||
req.user = user;
|
const user = await Auth.checkApiToken(tokenStr);
|
||||||
return next();
|
if(user && user.uid){
|
||||||
|
req.user = user;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Machine token (ServiceToken)
|
||||||
|
const { ServiceToken } = require('../models/token');
|
||||||
|
let svcToken;
|
||||||
|
try { svcToken = await ServiceToken.get(tokenStr); } catch(e) {}
|
||||||
|
if (svcToken && svcToken.is_valid) {
|
||||||
|
req.user = { uid: svcToken.resource_id, isMachine: true, name: 'Machine Account' };
|
||||||
|
req.resourceId = svcToken.resource_id;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,11 @@ exports.invite = rateLimit({
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
|
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public, unauthenticated, reads from disk on every request -- generous
|
||||||
|
// since it's just docs, but still throttled per IP.
|
||||||
|
exports.docs = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 120,
|
||||||
|
handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service access requests: the "request" half of the directory catalog.
|
||||||
|
//
|
||||||
|
// A request is a *proposal to join an LDAP group*. Approving one does exactly
|
||||||
|
// what an admin would have done by hand -- add the user to `groupCn` -- so LDAP
|
||||||
|
// remains the single access-control truth and this table is only the paper
|
||||||
|
// trail of who asked, who decided, and when. Nothing here grants anything on
|
||||||
|
// its own; a row with status 'approved' whose LDAP write failed is a row that
|
||||||
|
// grants no access, which is the safe direction.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
PENDING: 'pending',
|
||||||
|
APPROVED: 'approved',
|
||||||
|
DENIED: 'denied',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccessRequest extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// The requesting user's uid (not dn): dn changes if the directory is
|
||||||
|
// restructured, uid is the stable handle used everywhere else in the app.
|
||||||
|
uid: { type: 'string', isRequired: true },
|
||||||
|
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
|
||||||
|
// The group joining which satisfies this request. Captured at request time
|
||||||
|
// so a later re-link of the resource's groups can't silently redirect a
|
||||||
|
// pending approval at a different group than the one that was reviewed.
|
||||||
|
groupCn: { type: 'string', isRequired: true },
|
||||||
|
status: { type: 'string', isRequired: true, default: STATUS.PENDING },
|
||||||
|
note: { type: 'text' },
|
||||||
|
requestedOn: { type: 'integer' },
|
||||||
|
decidedBy: { type: 'string' },
|
||||||
|
decidedOn: { type: 'integer' },
|
||||||
|
decisionNote: { type: 'text' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one request that blocks a new one: same user, same group, still open.
|
||||||
|
// Denied/cancelled requests deliberately do not block -- circumstances change
|
||||||
|
// and a user may ask again.
|
||||||
|
static async findOpen(uid, groupCn) {
|
||||||
|
const rows = await this.list({ where: { uid, groupCn, status: STATUS.PENDING } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listForUser(uid) {
|
||||||
|
return this.list({ where: { uid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listPending() {
|
||||||
|
return this.list({ where: { status: STATUS.PENDING } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AccessRequest, STATUS };
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A theta-agent enrolled against this SSO.
|
||||||
|
//
|
||||||
|
// Before this model existed the "agent token" was generated in the browser and
|
||||||
|
// never recorded anywhere, so the server had no way to tell an agent it issued
|
||||||
|
// from one someone invented -- /api/agent/ws accepted any string, and there was
|
||||||
|
// no way to revoke a token or to know that an agent existed while it was
|
||||||
|
// offline. The row is now the authority: an agent is only real if it is here.
|
||||||
|
//
|
||||||
|
// The raw token is shown exactly once, at enrollment. Only its SHA-256 lands in
|
||||||
|
// the database, so a database disclosure does not hand over working agent
|
||||||
|
// credentials. `tokenPrefix` is the first 8 characters, kept in the clear so the
|
||||||
|
// UI and logs can identify an agent without holding the secret.
|
||||||
|
class Agent extends Model {
|
||||||
|
// Tokens are compared by hash on every WebSocket connect. SHA-256 (not
|
||||||
|
// bcrypt) is deliberate: this runs on the connection path and the token is a
|
||||||
|
// 256-bit random value, not a human-chosen password, so there is nothing for
|
||||||
|
// a slow KDF to protect against here.
|
||||||
|
static hashToken(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented token to its (non-revoked) agent, or null. Every
|
||||||
|
// caller that authenticates an agent must go through here.
|
||||||
|
static async authenticate(rawToken) {
|
||||||
|
if (!rawToken || typeof rawToken !== 'string') return null;
|
||||||
|
const tokenHash = this.hashToken(rawToken);
|
||||||
|
const matches = await this.list({ where: { tokenHash } });
|
||||||
|
const agent = matches && matches[0];
|
||||||
|
if (!agent) return null;
|
||||||
|
if (agent.revoked) return null;
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll a new agent and return { agent, token }. The caller is responsible
|
||||||
|
// for showing `token` to the operator once and never storing it.
|
||||||
|
static async enroll({ name, resourceId, enrolledBy, description }) {
|
||||||
|
const token = this.generateToken();
|
||||||
|
const agent = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: name || 'theta-agent',
|
||||||
|
description: description || null,
|
||||||
|
tokenHash: this.hashToken(token),
|
||||||
|
tokenPrefix: token.slice(0, 8),
|
||||||
|
resourceId: resourceId || null,
|
||||||
|
revoked: false,
|
||||||
|
enrolled_by: enrolledBy || null,
|
||||||
|
enrolled_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
return { agent, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue a fresh token for an existing agent, invalidating the old one.
|
||||||
|
async rotateToken() {
|
||||||
|
const token = Agent.generateToken();
|
||||||
|
await this.update({
|
||||||
|
tokenHash: Agent.hashToken(token),
|
||||||
|
tokenPrefix: token.slice(0, 8),
|
||||||
|
revoked: false
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
name: { type: 'string', isRequired: true },
|
||||||
|
description: { type: 'text' },
|
||||||
|
// Never the raw token. See hashToken above.
|
||||||
|
tokenHash: { type: 'string', isRequired: true },
|
||||||
|
tokenPrefix: { type: 'string' },
|
||||||
|
// The host this agent runs on. Nullable so an agent can be enrolled
|
||||||
|
// before its host exists in the Directory, but the UI pushes for it:
|
||||||
|
// without this link there is nothing to hang resource control off, and
|
||||||
|
// the old code had to guess by matching hostnames to slugs.
|
||||||
|
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
enrolled_by: { type: 'string' },
|
||||||
|
enrolled_on: { type: 'integer' },
|
||||||
|
// Survives a restart, which the in-memory map did not: an agent that is
|
||||||
|
// installed but currently down is now distinguishable from one that was
|
||||||
|
// never enrolled.
|
||||||
|
version: { type: 'string' },
|
||||||
|
last_seen: { type: 'integer' },
|
||||||
|
last_ip: { type: 'string' },
|
||||||
|
lastDiscovery: { type: 'json', default: {} },
|
||||||
|
lastTelemetry: { type: 'json', default: {} }
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shape the admin API returns. Never includes tokenHash.
|
||||||
|
toPublic(liveState) {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.tokenHash;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
version: data.version || (data.lastDiscovery && data.lastDiscovery.version) || (data.lastTelemetry && data.lastTelemetry.version) || 'unknown',
|
||||||
|
lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null,
|
||||||
|
connected: !!(liveState && liveState.connected),
|
||||||
|
// "Online" is a live-connection fact, not a stored one. A row with a
|
||||||
|
// last_seen from an hour ago is an installed agent that is down.
|
||||||
|
isOnline: !!(liveState && liveState.connected),
|
||||||
|
lastResponse: (liveState && liveState.lastResponse) || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A join key: the one credential an operator hands out so a host can enroll
|
||||||
|
// itself. Requiring an admin to pre-register every machine before the agent
|
||||||
|
// would talk to them made adding a host a two-system chore -- installing the
|
||||||
|
// agent should be enough.
|
||||||
|
//
|
||||||
|
// A join key is NOT the agent's long-term credential. On first connect the
|
||||||
|
// server auto-enrolls the host and issues it a unique per-agent token, which
|
||||||
|
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
|
||||||
|
// operator experience to "one key" while still giving every host its own
|
||||||
|
// revocable identity -- revoking a single agent means something, and a host
|
||||||
|
// that is compromised does not hand over the credential for the whole fleet.
|
||||||
|
class AgentJoinKey extends Model {
|
||||||
|
static hashKey(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKey() {
|
||||||
|
// `tjk_` so an operator can tell a join key from an agent token at a
|
||||||
|
// glance -- they are handled very differently.
|
||||||
|
return 'tjk_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented key to a usable join key, or null. Expiry and
|
||||||
|
// revocation are both enforced here so no caller can forget one.
|
||||||
|
static async authenticate(rawKey) {
|
||||||
|
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
const matches = await this.list({ where: { keyHash } });
|
||||||
|
const key = matches && matches[0];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.revoked) return null;
|
||||||
|
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue({ label, createdBy, expiresInDays }) {
|
||||||
|
const raw = this.generateKey();
|
||||||
|
const key = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: label || 'default',
|
||||||
|
keyHash: this.hashKey(raw),
|
||||||
|
keyPrefix: raw.slice(0, 12),
|
||||||
|
revoked: false,
|
||||||
|
created_by: createdBy || null,
|
||||||
|
created_on: Math.floor(Date.now() / 1000),
|
||||||
|
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||||
|
use_count: 0
|
||||||
|
});
|
||||||
|
return { key, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
label: { type: 'string', isRequired: true },
|
||||||
|
keyHash: { type: 'string', isRequired: true },
|
||||||
|
keyPrefix: { type: 'string' },
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
expires_on: { type: 'integer' },
|
||||||
|
use_count: { type: 'integer', default: 0 },
|
||||||
|
last_used_on: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.keyHash;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Agent, AgentJoinKey };
|
||||||
@@ -23,7 +23,7 @@ Auth.login = async function(data){
|
|||||||
|
|
||||||
return {user, token}
|
return {user, token}
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error("AUTH LOGIN error:", error);
|
console.error("AUTH LOGIN error:", error.name, error.message);
|
||||||
throw this.errors.login();
|
throw this.errors.login();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
|
|||||||
|
|
||||||
var transporter = nodemailer.createTransport(transportOpts);
|
var transporter = nodemailer.createTransport(transportOpts);
|
||||||
|
|
||||||
|
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
|
||||||
|
// ...: Sender is not same as SMTP authenticate username") require the
|
||||||
|
// envelope/header From to equal the authenticated user, or reject the
|
||||||
|
// send outright. If the operator hasn't set an explicit smtp.from,
|
||||||
|
// defaulting to the SMTP username is far more likely to actually send
|
||||||
|
// than a made-up noreply@theta42.com address that no relay authorized
|
||||||
|
// this account to send as.
|
||||||
var mailOpts = {
|
var mailOpts = {
|
||||||
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
|
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||||
to: to,
|
to: to,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: message
|
html: message
|
||||||
@@ -58,7 +65,7 @@ Mail.sendTemplate = async function(to, template, context, from){
|
|||||||
to,
|
to,
|
||||||
mustache.render(template.subject, context),
|
mustache.render(template.subject, context),
|
||||||
mustache.render(template.message, context),
|
mustache.render(template.message, context),
|
||||||
from || (template.from && mustache.render(template.message, context))
|
from || (template.from && mustache.render(template.from, context))
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,22 @@
|
|||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const { LRUCache } = require('lru-cache');
|
const { LRUCache } = require('lru-cache');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
|
||||||
|
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
|
||||||
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
|
const escapeLDAPDNValue = escapeDN;
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroups(client, member){
|
async function getGroups(client, member){
|
||||||
let memberFilter = member ? `(member=${member})`: ''
|
let memberFilter = member ? `(member=${escapeLDAPSearchValue(member)})`: ''
|
||||||
|
|
||||||
let groups = (await client.search(conf.groupBase, {
|
let groups = (await client.search(conf.groupBase, {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
@@ -35,7 +34,8 @@ async function getGroups(client, member){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addGroup(client, data){
|
async function addGroup(client, data){
|
||||||
await client.add(`cn=${data.name},${conf.groupBase}`, {
|
const safeName = escapeLDAPDNValue(data.name);
|
||||||
|
await client.add(`cn=${safeName},${conf.groupBase}`, {
|
||||||
cn: data.name,
|
cn: data.name,
|
||||||
member: data.owner,
|
member: data.owner,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -112,18 +112,190 @@ async function cachedListDetail() {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nested groups -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `groupOfNames.member` holds DNs, and nothing says those DNs must be users --
|
||||||
|
// a group DN is a perfectly legal member. That is how nesting is stored here:
|
||||||
|
// as-is, no extra schema, no denormalization, the nesting visible in LDAP
|
||||||
|
// exactly as an admin entered it.
|
||||||
|
//
|
||||||
|
// What LDAP will NOT do is resolve it. The memberof overlay records only
|
||||||
|
// *direct* membership, and a `(member=<dn>)` filter likewise finds only the
|
||||||
|
// groups that list the DN literally. So transitivity is computed here, and
|
||||||
|
// every membership question in the app must go through these helpers or it
|
||||||
|
// will silently see one level and grant nothing for a nested group.
|
||||||
|
//
|
||||||
|
// The whole group set is one subtree search, so the closure is computed in
|
||||||
|
// memory rather than issuing a query per level. `resolverCache` keeps that
|
||||||
|
// search off the hot path for bursts; it is cleared by every write below, so
|
||||||
|
// the only staleness it can introduce is from edits made outside this app.
|
||||||
|
// Auth decisions ride on this, hence the deliberately short TTL.
|
||||||
|
|
||||||
|
const NESTING_TTL_MS = 15 * 1000;
|
||||||
|
const MAX_NESTING_DEPTH = Number(conf.groupNestingDepth) > 0 ? Number(conf.groupNestingDepth) : 10;
|
||||||
|
|
||||||
|
const resolverCache = new LRUCache({ max: 1, ttl: NESTING_TTL_MS, ttlAutopurge: true });
|
||||||
|
|
||||||
|
async function allGroupsForResolver() {
|
||||||
|
const hit = resolverCache.get('all');
|
||||||
|
if (hit) return hit;
|
||||||
|
const promise = withClient(async (client) => {
|
||||||
|
const groups = await getGroups(client);
|
||||||
|
return groups.map(g => ({ ...g }));
|
||||||
|
}).then(plain => {
|
||||||
|
resolverCache.set('all', plain);
|
||||||
|
return plain;
|
||||||
|
}).catch(err => {
|
||||||
|
resolverCache.delete('all');
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
resolverCache.set('all', promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lc = dn => String(dn || '').toLowerCase();
|
||||||
|
|
||||||
|
// dn -> [groups that list dn as a member]. One pass, reused for every lookup.
|
||||||
|
function buildParentIndex(groups) {
|
||||||
|
const parents = new Map();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const member of [].concat(group.member || []).filter(Boolean)) {
|
||||||
|
const key = lc(member);
|
||||||
|
if (!parents.has(key)) parents.set(key, []);
|
||||||
|
parents.get(key).push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every group `dn` belongs to, directly or through any chain of nested groups.
|
||||||
|
// Breadth-first with a visited set, so a cycle (A in B, B in A) terminates
|
||||||
|
// instead of hanging, and MAX_NESTING_DEPTH bounds a pathological chain.
|
||||||
|
function closureUp(dn, groups) {
|
||||||
|
const parents = buildParentIndex(groups);
|
||||||
|
const found = new Map(); // cn -> group
|
||||||
|
const seen = new Set([lc(dn)]);
|
||||||
|
let frontier = [lc(dn)];
|
||||||
|
|
||||||
|
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||||
|
const next = [];
|
||||||
|
for (const current of frontier) {
|
||||||
|
for (const group of parents.get(current) || []) {
|
||||||
|
const groupDn = lc(group.dn);
|
||||||
|
if (seen.has(groupDn)) continue;
|
||||||
|
seen.add(groupDn);
|
||||||
|
found.set(group.cn, group);
|
||||||
|
// The group itself is now a member to look up: this is the step
|
||||||
|
// that makes the walk transitive rather than one-level.
|
||||||
|
next.push(groupDn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return [...found.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every member DN reachable from a group, split into the users it effectively
|
||||||
|
// grants and the groups it nests. `direct` is kept separate so the UI can show
|
||||||
|
// "3 members, 12 effective" and so removal stays unambiguous.
|
||||||
|
function closureDown(group, groups) {
|
||||||
|
const byDn = new Map(groups.map(g => [lc(g.dn), g]));
|
||||||
|
const users = new Set();
|
||||||
|
const nested = new Map();
|
||||||
|
const seen = new Set([lc(group.dn)]);
|
||||||
|
let frontier = [group];
|
||||||
|
|
||||||
|
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||||
|
const next = [];
|
||||||
|
for (const current of frontier) {
|
||||||
|
for (const member of [].concat(current.member || []).filter(Boolean)) {
|
||||||
|
const key = lc(member);
|
||||||
|
const asGroup = byDn.get(key);
|
||||||
|
if (asGroup) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
nested.set(asGroup.cn, asGroup);
|
||||||
|
next.push(asGroup);
|
||||||
|
} else {
|
||||||
|
users.add(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return { users: [...users], nested: [...nested.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
var Group = {};
|
var Group = {};
|
||||||
|
|
||||||
|
// Set when slapd carries the nestgroup overlay (docker-entrypoint.sh exports
|
||||||
|
// app_ldap__nestedGroupsServerSide=true after detecting nestgroup.so). With it,
|
||||||
|
// a plain `(member=<dn>)` search already returns the full transitive set and the
|
||||||
|
// in-app closure is redundant work on every request. Without it -- e.g. pointed
|
||||||
|
// at a stock 2.6.x server, which no release ships nestgroup in -- the app must
|
||||||
|
// compute the closure itself or nested groups silently grant nothing.
|
||||||
|
const SERVER_SIDE_NESTING = String(conf.nestedGroupsServerSide) === 'true';
|
||||||
|
|
||||||
|
// Transitive: every group CN this member belongs to, at any nesting depth.
|
||||||
|
// Callers making an access decision must use this rather than reading
|
||||||
|
// `memberOf`, which a server without nestgroup only ever populates one level
|
||||||
|
// deep.
|
||||||
Group.list = async function(member){
|
Group.list = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => {
|
if (SERVER_SIDE_NESTING) {
|
||||||
const groups = await getGroups(client, member);
|
return withClient(async (client) => {
|
||||||
return groups.map(group => group.cn);
|
const groups = await getGroups(client, member);
|
||||||
});
|
return groups.map(group => group.cn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
return closureUp(member, groups).map(group => group.cn);
|
||||||
}
|
}
|
||||||
return (await cachedListDetail()).map(group => group.cn);
|
return (await cachedListDetail()).map(group => group.cn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The members a group effectively grants: users reached through any chain of
|
||||||
|
// nested groups, plus the nested groups themselves for display.
|
||||||
|
Group.effectiveMembers = async function(cn){
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
const group = groups.find(g => g.cn === cn);
|
||||||
|
if (!group) {
|
||||||
|
let error = new Error('GroupNotFound');
|
||||||
|
error.name = 'GroupNotFound';
|
||||||
|
error.message = `LDAP:${cn} does not exists`;
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { users, nested } = closureDown(group, groups);
|
||||||
|
const directMembers = [].concat(group.member || []).filter(Boolean);
|
||||||
|
const groupDns = new Set(groups.map(g => lc(g.dn)));
|
||||||
|
return {
|
||||||
|
cn: group.cn,
|
||||||
|
direct: directMembers.filter(dn => !groupDns.has(lc(dn))),
|
||||||
|
nestedGroups: nested.map(g => ({ cn: g.cn, dn: g.dn })),
|
||||||
|
effective: users,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Would adding `childDn` to `parentCn` create a cycle? A group may not contain
|
||||||
|
// itself, nor anything that already (transitively) contains it -- such a chain
|
||||||
|
// makes membership unanswerable, and callers would rely on the depth cap to
|
||||||
|
// stop rather than getting a real answer.
|
||||||
|
Group.wouldCycle = async function(parentCn, childDn){
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
const parent = groups.find(g => g.cn === parentCn);
|
||||||
|
if (!parent) return false;
|
||||||
|
if (lc(parent.dn) === lc(childDn)) return true;
|
||||||
|
const child = groups.find(g => lc(g.dn) === lc(childDn));
|
||||||
|
if (!child) return false; // a user DN can never close a cycle
|
||||||
|
// Adding child under parent is a cycle exactly when parent is already
|
||||||
|
// reachable downward from child.
|
||||||
|
const { nested } = closureDown(child, groups);
|
||||||
|
return nested.some(g => lc(g.dn) === lc(parent.dn));
|
||||||
|
};
|
||||||
|
|
||||||
|
Group.clearResolverCache = function(){ resolverCache.clear(); };
|
||||||
|
|
||||||
Group.listDetail = async function(member){
|
Group.listDetail = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => getGroups(client, member));
|
return withClient(async (client) => getGroups(client, member));
|
||||||
@@ -139,9 +311,10 @@ Group.get = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
return withClient(async (client) => {
|
return withClient(async (client) => {
|
||||||
|
const safeName = escapeLDAPSearchValue(data.name);
|
||||||
let group = (await client.search(conf.groupBase, {
|
let group = (await client.search(conf.groupBase, {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`,
|
filter: `(&(objectClass=groupOfNames)(cn=${safeName}))`,
|
||||||
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
|
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
|
||||||
})).searchEntries[0];
|
})).searchEntries[0];
|
||||||
|
|
||||||
@@ -165,6 +338,7 @@ Group.add = async function(data){
|
|||||||
return withClient(async (client) => {
|
return withClient(async (client) => {
|
||||||
await addGroup(client, data);
|
await addGroup(client, data);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this.get(data);
|
return this.get(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,6 +347,7 @@ Group.addMember = async function(user){
|
|||||||
await withClient(async (client) => addMember(client, this, user));
|
await withClient(async (client) => addMember(client, this, user));
|
||||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,6 +360,7 @@ Group.removeMember = async function(user){
|
|||||||
}
|
}
|
||||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,6 +368,7 @@ Group.addOwner = async function(user){
|
|||||||
await withClient(async (client) => addOwner(client, this, user));
|
await withClient(async (client) => addOwner(client, this, user));
|
||||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,12 +381,14 @@ Group.removeOwner = async function(user){
|
|||||||
}
|
}
|
||||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
Group.remove = async function(){
|
Group.remove = async function(){
|
||||||
await withClient(async (client) => client.del(this.dn));
|
await withClient(async (client) => client.del(this.dn));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,84 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const {setUpTable} = require('model-redis');
|
const { setUpTable } = require('model-redis');
|
||||||
|
|
||||||
|
// Keep model-redis for the ones not yet ported
|
||||||
const Table = setUpTable(conf.redis);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
module.exports = Table;
|
module.exports = Table;
|
||||||
|
|
||||||
require('./token');
|
const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
|
||||||
require('./verification');
|
require('./verification');
|
||||||
require('./oauth_client');
|
|
||||||
require('./oauth_code');
|
require('./oauth_code');
|
||||||
require('./api_token');
|
require('./api_token');
|
||||||
|
|
||||||
|
const { init } = require('@simpleworkjs/orm');
|
||||||
|
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||||
|
const { AccessRequest } = require('./access_request');
|
||||||
|
const { Webhook } = require('./webhook');
|
||||||
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
|
const { SharedSecret } = require('./shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
|
const { Agent, AgentJoinKey } = require('./agent');
|
||||||
|
const { SiteJoinKey } = require('./site_join_key');
|
||||||
|
const { SiteSpoke } = require('./site_spoke');
|
||||||
|
async function initORM() {
|
||||||
|
const ormConf = conf.orm || {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: './config/inventory.sqlite',
|
||||||
|
logging: false
|
||||||
|
};
|
||||||
|
ormConf.redis = conf.redis;
|
||||||
|
|
||||||
|
console.log('[initORM] Starting ORM initialization...');
|
||||||
|
try {
|
||||||
|
await init({
|
||||||
|
conf: { orm: ormConf },
|
||||||
|
models: [
|
||||||
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey, SiteSpoke,
|
||||||
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log('[initORM] ORM initialized successfully');
|
||||||
|
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||||
|
await healSchema();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[initORM] ORM initialization failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
|
||||||
|
// which creates missing tables but never touches existing ones — so a column
|
||||||
|
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
|
||||||
|
// in an upgraded deployment's database and every query on the model fails
|
||||||
|
// ("no such column"). This walks each Sequelize model and ADDs any attribute
|
||||||
|
// missing from its table. Strictly additive (never drops or retypes), works on
|
||||||
|
// any dialect via the query interface, and fail-soft per column so one bad
|
||||||
|
// attribute can't take the boot down.
|
||||||
|
async function healSchema() {
|
||||||
|
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
|
||||||
|
if (!adapter || !adapter.sequelize) return;
|
||||||
|
const sequelize = adapter.sequelize;
|
||||||
|
const qi = sequelize.getQueryInterface();
|
||||||
|
for (const SM of Object.values(sequelize.models)) {
|
||||||
|
const table = SM.getTableName();
|
||||||
|
let existing;
|
||||||
|
try { existing = await qi.describeTable(table); }
|
||||||
|
catch (e) { continue; } // no table yet — sync() handles creation
|
||||||
|
for (const [name, attr] of Object.entries(SM.getAttributes())) {
|
||||||
|
const col = attr.field || name;
|
||||||
|
if (existing[col]) continue;
|
||||||
|
try {
|
||||||
|
await qi.addColumn(table, col, attr);
|
||||||
|
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -1,50 +1,137 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const { Resource } = require('./resource');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
const crypto = require('crypto');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const UUID = () => crypto.randomUUID();
|
||||||
|
|
||||||
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
||||||
access_token: 3600,
|
access_token: 3600,
|
||||||
refresh_token: 2592000
|
refresh_token: 2592000
|
||||||
};
|
};
|
||||||
|
|
||||||
class OAuthClient extends Table {
|
class OAuthClient {
|
||||||
static _key = 'client_id';
|
|
||||||
static _keyMap = {
|
|
||||||
'client_id': {default: UUID, type: 'string'},
|
|
||||||
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
|
||||||
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
|
||||||
'description': {default: '', type: 'string'},
|
|
||||||
'redirect_uris': {default: [], type: 'object'},
|
|
||||||
'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'},
|
|
||||||
'allowed_groups': {default: [], type: 'object'},
|
|
||||||
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
|
|
||||||
'created_by': {isRequired: true, type: 'string'},
|
|
||||||
'created_on': {default: function(){ return (new Date).getTime() }},
|
|
||||||
'is_valid': {default: true, type: 'boolean'},
|
|
||||||
}
|
|
||||||
|
|
||||||
static async add(data) {
|
static async add(data) {
|
||||||
const raw_secret = UUID();
|
const raw_secret = crypto.randomUUID();
|
||||||
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
const client_id = crypto.randomUUID();
|
||||||
data.client_id = UUID();
|
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||||
const client = await this.create(data);
|
|
||||||
client._raw_secret = raw_secret;
|
// Generate a unique slug from the client name
|
||||||
return client;
|
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
|
||||||
|
// Ensure uniqueness by appending a suffix if needed
|
||||||
|
const existing = await Resource.list({ where: { slug } });
|
||||||
|
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
const r = await Resource.create({
|
||||||
|
id: client_id,
|
||||||
|
kind: 'oauth',
|
||||||
|
name: data.name,
|
||||||
|
slug: slug,
|
||||||
|
description: data.description || '',
|
||||||
|
owner: data.created_by,
|
||||||
|
metadata: {
|
||||||
|
client_secret_hash,
|
||||||
|
redirect_uris: data.redirect_uris || [],
|
||||||
|
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
|
||||||
|
allowed_groups: data.allowed_groups || [],
|
||||||
|
token_lifetime: data.token_lifetime || { ...defaultLifetime }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
r._raw_secret = raw_secret;
|
||||||
|
r.client_id = client_id;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
static async get(client_id) {
|
||||||
|
const notFound = () => {
|
||||||
|
const e = new Error('OAuthClient not found');
|
||||||
|
e.status = 404;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await Resource.get(client_id);
|
||||||
|
} catch (_) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
// Resource.get() returns null (does not throw) for a missing id —
|
||||||
|
// guard it so a bad/undefined client_id is a clean 404, not a
|
||||||
|
// "Cannot read properties of null (reading 'kind')" 500.
|
||||||
|
if (!r || r.kind !== 'oauth') throw notFound();
|
||||||
|
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||||
|
r.client_id = r.id;
|
||||||
|
r.client_secret_hash = r.metadata.client_secret_hash;
|
||||||
|
r.redirect_uris = r.metadata.redirect_uris || [];
|
||||||
|
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
|
||||||
|
r.allowed_groups = r.metadata.allowed_groups || [];
|
||||||
|
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
|
||||||
|
// Resource has no is_valid column; validity lives in metadata (absent = valid)
|
||||||
|
r.is_valid = r.metadata.is_valid !== false;
|
||||||
|
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
|
||||||
|
|
||||||
|
r.rotateSecret = async () => {
|
||||||
|
const raw_secret = crypto.randomUUID();
|
||||||
|
r.metadata.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||||
|
await r.update({ metadata: r.metadata });
|
||||||
|
return raw_secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The ORM Model.toJSON() only serializes schema fields, so the mapped
|
||||||
|
// properties above (client_id, scopes, redirect_uris, …) would be
|
||||||
|
// stripped from any res.json() — that's why GET /api/oauth/client
|
||||||
|
// returned client_id: undefined and the bootstrap's rotate blew up.
|
||||||
|
// Emit the public shape explicitly. client_secret_hash is deliberately
|
||||||
|
// omitted so it never leaks over the API.
|
||||||
|
r.toJSON = function () {
|
||||||
|
return {
|
||||||
|
client_id: r.id,
|
||||||
|
id: r.id,
|
||||||
|
kind: r.kind,
|
||||||
|
name: r.name,
|
||||||
|
slug: r.slug,
|
||||||
|
owner: r.owner,
|
||||||
|
description: r.description,
|
||||||
|
redirect_uris: r.redirect_uris,
|
||||||
|
scopes: r.scopes,
|
||||||
|
allowed_groups: r.allowed_groups,
|
||||||
|
token_lifetime: r.token_lifetime,
|
||||||
|
is_valid: r.is_valid,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// proxy update to handle metadata correctly
|
||||||
|
const originalUpdate = r.update.bind(r);
|
||||||
|
r.update = async (data) => {
|
||||||
|
if (data.redirect_uris !== undefined) r.metadata.redirect_uris = data.redirect_uris;
|
||||||
|
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
|
||||||
|
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
|
||||||
|
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
|
||||||
|
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
|
||||||
|
|
||||||
|
const updateData = { metadata: r.metadata };
|
||||||
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
|
|
||||||
|
return originalUpdate(updateData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifySecret(secret) {
|
static async list() {
|
||||||
return bcrypt.compare(secret, this.client_secret_hash);
|
const resources = await Resource.list({ where: { kind: 'oauth' } });
|
||||||
|
return Promise.all(resources.map(r => this.get(r.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotateSecret() {
|
static async listDetail() {
|
||||||
const raw_secret = UUID();
|
return this.list();
|
||||||
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
|
}
|
||||||
return raw_secret;
|
|
||||||
|
static async verifySecret(client_id, secret) {
|
||||||
|
const client = await this.get(client_id);
|
||||||
|
return client.verifySecret(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OAuthClient.register();
|
|
||||||
|
|
||||||
module.exports = { OAuthClient };
|
module.exports = { OAuthClient };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const Table = require('.');
|
||||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
const crypto = require('crypto');
|
||||||
|
const UUID = () => crypto.randomUUID();
|
||||||
|
|
||||||
// Shared base keyMap matching Token's schema so these behave as tokens
|
// Shared base keyMap matching Token's schema so these behave as tokens
|
||||||
const tokenKeyMap = {
|
const tokenKeyMap = {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// PluginInstance — the registry of configured, loadable plugin copies.
|
||||||
|
//
|
||||||
|
// The SSO plugin system (see nodejs/services/plugin_registry.js) distinguishes
|
||||||
|
// **plugin types** (the .js modules under nodejs/plugins/<category>/<type>.js)
|
||||||
|
// from **plugin instances** — a configured, loadable/unloadable *copy* of a
|
||||||
|
// type. You can have several instances of the same type (e.g. two Proxmox
|
||||||
|
// endpoints with their own URLs + tokens), each on its own schedule.
|
||||||
|
//
|
||||||
|
// This table holds the *non-secret* per-instance state: which type it is, its
|
||||||
|
// schedule (cron), whether it's loaded (enabled), and its non-secret config.
|
||||||
|
// Per-instance **secrets** (the configSchema fields flagged `secret:true`,
|
||||||
|
// e.g. a Proxmox `tokenSecret` or UniFi `password`) live in OpenBao at
|
||||||
|
// `secret/plugins/<id>/conf` (see nodejs/utils/plugin_secrets.js) — never in
|
||||||
|
// the DB. The DB row's `config` JSON column holds only non-secret field values.
|
||||||
|
//
|
||||||
|
// `slug` is the discovery source name passed to DiscoveryReconciler.reconcile,
|
||||||
|
// so a discovery instance's resources are attributed to a stable, human-chosen
|
||||||
|
// name rather than its uuid. Unique, so two instances can't shadow each other
|
||||||
|
// in the resource graph's `discovery_sources`.
|
||||||
|
//
|
||||||
|
// Like Resource/AccessRequest, there is no ORM auto-timestamp hook: the route
|
||||||
|
// handler stamps created_by/on + updated_by/on explicitly on every write (see
|
||||||
|
// routes/api_plugins.js). `id` (uuid) is generated by the ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
OK: 'ok',
|
||||||
|
ERROR: 'error',
|
||||||
|
RUNNING: 'running',
|
||||||
|
};
|
||||||
|
|
||||||
|
class PluginInstance extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// A registered plugin type slug (matches a manifest `type`). Validated
|
||||||
|
// against the registry before a row is created.
|
||||||
|
pluginType: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// The plugin's category (e.g. 'discovery'). Copied from the manifest at
|
||||||
|
// create time so the scheduler can dispatch without re-reading the registry
|
||||||
|
// on every run (and so a later type removal still shows what the instance was).
|
||||||
|
category: { type: 'string', isRequired: true, default: 'discovery', min: 1, max: 64 },
|
||||||
|
// Human label for the instance.
|
||||||
|
name: { type: 'string', isRequired: true, min: 1, max: 120 },
|
||||||
|
// Stable handle: discovery source name + unique constraint. Lowercase
|
||||||
|
// alnum + hyphen/underscore to stay safe as a resource-graph slug.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// Loaded into the scheduler? `false` = unloaded (no scheduled runs).
|
||||||
|
enabled: { type: 'boolean', default: true },
|
||||||
|
// Cron schedule (5-field). The scheduler turns this into a BullMQ
|
||||||
|
// repeatable JobScheduler.
|
||||||
|
cron: { type: 'string', isRequired: true, default: '0 * * * *' },
|
||||||
|
// Non-secret configSchema field values. Secret fields are NOT here.
|
||||||
|
config: { type: 'json', default: {} },
|
||||||
|
// Last-run bookkeeping, updated by the scheduler worker.
|
||||||
|
lastRunAt: { type: 'integer' },
|
||||||
|
lastStatus: { type: 'string' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
lastLog: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All instances the scheduler should run: enabled only. Loaded fresh each
|
||||||
|
// boot / load; not cached on the model (the scheduler is the source of truth
|
||||||
|
// for what's actually scheduled).
|
||||||
|
static async listEnabled() {
|
||||||
|
return this.list({ where: { enabled: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug — used by tests + the reconciler when only a slug is known.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginInstance, STATUS };
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const { Group } = require('./group_ldap');
|
||||||
|
|
||||||
|
class Resource extends Model {
|
||||||
|
static exposedMethods = [
|
||||||
|
{ method: 'search', route: 'resources', verb: 'get', args: { from: 'query' } },
|
||||||
|
{ method: 'getBySlug', route: 'resources/:slug', verb: 'get', args: { from: 'params', names: ['slug'] } },
|
||||||
|
{ method: 'getGraph', route: 'graph', verb: 'get' },
|
||||||
|
{ method: 'getMyAccess', route: 'me', verb: 'get', args: { from: 'user' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
static async search(query) {
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
let resources = graph.resources;
|
||||||
|
|
||||||
|
if (query.kind) {
|
||||||
|
resources = resources.filter(r => r.kind === query.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.group) {
|
||||||
|
const rgs = await ResourceGroup.list({ where: { groupCn: query.group } });
|
||||||
|
const allowedIds = new Set(rgs.map(rg => rg.resourceId));
|
||||||
|
resources = resources.filter(r => allowedIds.has(r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.parent) {
|
||||||
|
const parents = graph.resources.filter(r => r.slug === query.parent);
|
||||||
|
if (parents.length > 0) {
|
||||||
|
const parentId = parents[0].id;
|
||||||
|
const childIds = new Set(graph.edges.filter(e => e.parentId === parentId).map(e => e.childId));
|
||||||
|
resources = resources.filter(r => childIds.has(r.id));
|
||||||
|
} else {
|
||||||
|
resources = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
const resource = graph.resources.find(r => r.slug === slug);
|
||||||
|
if (!resource) {
|
||||||
|
let err = new Error('Resource not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parents = graph.edges.filter(e => e.childId === resource.id);
|
||||||
|
const children = graph.edges.filter(e => e.parentId === resource.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...resource,
|
||||||
|
parents,
|
||||||
|
children
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getGraph() {
|
||||||
|
const resources = await this.list();
|
||||||
|
const edges = await ResourceEdge.list();
|
||||||
|
|
||||||
|
// Convert to simple objects so we can mutate metadata properties safely
|
||||||
|
const resObjs = resources.map(r => {
|
||||||
|
const obj = r.toJSON ? r.toJSON() : { ...r };
|
||||||
|
obj.metadata = obj.metadata || {};
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bubble up production status: if any child is prod, parent is prod
|
||||||
|
const isProdCache = new Map();
|
||||||
|
function checkProd(resId, visited = new Set()) {
|
||||||
|
if (isProdCache.has(resId)) return isProdCache.get(resId);
|
||||||
|
if (visited.has(resId)) return false; // Cycle prevention
|
||||||
|
|
||||||
|
visited.add(resId);
|
||||||
|
const r = resObjs.find(x => x.id === resId);
|
||||||
|
if (!r) return false;
|
||||||
|
|
||||||
|
// If intrinsically prod, return true
|
||||||
|
if (r.metadata.isProduction) {
|
||||||
|
isProdCache.set(resId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children
|
||||||
|
const childrenIds = edges.filter(e => e.parentId === resId).map(e => e.childId);
|
||||||
|
for (const cid of childrenIds) {
|
||||||
|
if (checkProd(cid, visited)) {
|
||||||
|
isProdCache.set(resId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isProdCache.set(resId, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxUpdated = 0;
|
||||||
|
resObjs.forEach(r => {
|
||||||
|
r.metadata.isProduction = checkProd(r.id);
|
||||||
|
if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
|
||||||
|
// otherwise the nearest ancestor's. A service usually carries no address of
|
||||||
|
// its own -- it is reached at the host it runs on -- so "how do I reach this"
|
||||||
|
// is only answerable from the graph, never from the row alone. Every caller
|
||||||
|
// that answers that question for a user (getMyAccess, GET /api/discovery/me)
|
||||||
|
// must go through here, or services come back unreachable.
|
||||||
|
static async withResolvedAddress(resources) {
|
||||||
|
if (!resources || !resources.length) return [];
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
|
||||||
|
const resolve = (resId, visited = new Set()) => {
|
||||||
|
if (visited.has(resId)) return null; // prevent cycles
|
||||||
|
visited.add(resId);
|
||||||
|
|
||||||
|
const res = graph.resources.find(r => r.id === resId);
|
||||||
|
if (!res) return null;
|
||||||
|
if (res.metadata && res.metadata.address) return res.metadata.address;
|
||||||
|
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
||||||
|
|
||||||
|
for (const edge of graph.edges.filter(e => e.childId === resId)) {
|
||||||
|
const found = resolve(edge.parentId, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return resources.map(r => {
|
||||||
|
const data = r.toJSON ? r.toJSON() : { ...r };
|
||||||
|
data.metadata = data.metadata || {};
|
||||||
|
data.resolvedAddress = resolve(data.id);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getMyAccess(userDn) {
|
||||||
|
const userGroups = await Group.list(userDn);
|
||||||
|
if (!userGroups || userGroups.length === 0) return [];
|
||||||
|
|
||||||
|
const resourceGroups = await ResourceGroup.list({
|
||||||
|
where: { groupCn: { in: userGroups } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||||
|
if (resourceIds.length === 0) return [];
|
||||||
|
|
||||||
|
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
kind: { type: 'string', isRequired: true },
|
||||||
|
name: { type: 'string', isRequired: true },
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true },
|
||||||
|
owner: { type: 'string' },
|
||||||
|
description: { type: 'text' },
|
||||||
|
metadata: { type: 'json', default: {} },
|
||||||
|
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||||
|
// are set explicitly by the route handler on every create/update (see
|
||||||
|
// routes/api_directory_admin.js). Existing rows predating this change
|
||||||
|
// simply read back undefined -- callers must render a fallback.
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
|
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||||
|
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||||
|
// top-level resource with no site parent keeps its unprefixed group name).
|
||||||
|
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||||
|
if (visited.has(resourceId)) return null;
|
||||||
|
visited.add(resourceId);
|
||||||
|
|
||||||
|
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const parent = await this.get(edge.parentId);
|
||||||
|
if (!parent) continue;
|
||||||
|
if (parent.kind === 'site') return parent.slug;
|
||||||
|
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
||||||
|
// resources (Host, Cluster, Site, etc.).
|
||||||
|
static async findAllAncestors(resourceId, visited = new Set()) {
|
||||||
|
if (!resourceId || visited.has(resourceId)) return [];
|
||||||
|
visited.add(resourceId);
|
||||||
|
|
||||||
|
const ancestors = [];
|
||||||
|
const allEdges = await ResourceEdge.list().catch(() => []);
|
||||||
|
const parentEdges = allEdges.filter(e => e.childId === resourceId);
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const parent = await this.get(edge.parentId).catch(() => null);
|
||||||
|
if (!parent) continue;
|
||||||
|
ancestors.push(parent);
|
||||||
|
const higher = await this.findAllAncestors(parent.id, visited);
|
||||||
|
ancestors.push(...higher);
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResourceEdge extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
parent: { type: 'hasOne', model: 'Resource' }, // Creates parentId
|
||||||
|
child: { type: 'hasOne', model: 'Resource' }, // Creates childId
|
||||||
|
relation: { type: 'string', isRequired: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResourceGroup extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
resource: { type: 'hasOne', model: 'Resource' }, // Creates resourceId
|
||||||
|
groupCn: { type: 'string', isRequired: true },
|
||||||
|
accessLevel: { type: 'string', isRequired: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
// No DB-level unique constraint on (resourceId, groupCn) exists, so callers
|
||||||
|
// MUST check-then-create rather than relying on a constraint violation to
|
||||||
|
// catch a dupe. A caller that skips this (raw ResourceGroup.create()) and
|
||||||
|
// runs more than once for the same resource -- e.g. discovery reconciling
|
||||||
|
// the same LXC from multiple Proxmox cluster nodes -- silently accumulates
|
||||||
|
// duplicate access/admin rows every pass, with no error to notice it by.
|
||||||
|
static async ensure(resourceId, groupCn, accessLevel) {
|
||||||
|
const existing = await this.list({ where: { resourceId, groupCn } });
|
||||||
|
if (existing.length) return existing[0];
|
||||||
|
return this.create({ id: crypto.randomUUID(), resourceId, groupCn, accessLevel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Resource,
|
||||||
|
ResourceEdge,
|
||||||
|
ResourceGroup
|
||||||
|
};
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
|
|
||||||
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
|
|
||||||
// direct-LDAP bind account) or any other app/host that needs its own
|
|
||||||
// dedicated read-only credential, as opposed to a real user who logs into
|
|
||||||
// the web UI.
|
|
||||||
//
|
|
||||||
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
|
|
||||||
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
|
|
||||||
// objectClass matches exactly what theta-env's bootstrap.js already creates
|
|
||||||
// for cn=ldapclient, so this model recognizes and manages that account too,
|
|
||||||
// not just ones created through this UI.
|
|
||||||
|
|
||||||
const { Client, Attribute, Change } = require('ldapts');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
|
||||||
const salt = crypto.randomBytes(8);
|
|
||||||
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
|
|
||||||
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient() {
|
|
||||||
return new Client({ url: conf.url });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withClient(fn) {
|
|
||||||
const client = makeClient();
|
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
|
|
||||||
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
|
|
||||||
|
|
||||||
var ServiceAccount = {};
|
|
||||||
|
|
||||||
ServiceAccount.list = async function(){
|
|
||||||
return withClient(async (client) => {
|
|
||||||
const res = await client.search(conf.userBase, {
|
|
||||||
scope: 'sub',
|
|
||||||
filter: FILTER,
|
|
||||||
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
|
|
||||||
});
|
|
||||||
return res.searchEntries.map((entry) => ({
|
|
||||||
cn: entry.cn,
|
|
||||||
dn: `cn=${entry.cn},${conf.userBase}`,
|
|
||||||
description: entry.description || '',
|
|
||||||
created_on: entry.createTimestamp || null,
|
|
||||||
modified_on: entry.modifyTimestamp || null,
|
|
||||||
})).sort((a, b) => a.cn.localeCompare(b.cn));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.create = async function({cn, description}){
|
|
||||||
if(!cn || !CN_RE.test(cn)){
|
|
||||||
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
const password = crypto.randomBytes(24).toString('base64url');
|
|
||||||
|
|
||||||
await withClient(async (client) => {
|
|
||||||
let existing = true;
|
|
||||||
try{
|
|
||||||
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
|
|
||||||
existing = res.searchEntries.length > 0;
|
|
||||||
}catch(error){ existing = false; }
|
|
||||||
if(existing){
|
|
||||||
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.add(dn, {
|
|
||||||
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
|
|
||||||
cn,
|
|
||||||
description: description || '',
|
|
||||||
userPassword: hashPasswordSSHA512(password),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {cn, dn, description: description || '', password};
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.setPassword = async function(cn, password){
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
const newPassword = password || crypto.randomBytes(24).toString('base64url');
|
|
||||||
|
|
||||||
await withClient(async (client) => {
|
|
||||||
await client.modify(dn, [
|
|
||||||
new Change({
|
|
||||||
operation: 'replace',
|
|
||||||
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {cn, dn, password: newPassword};
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.remove = async function(cn){
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
await withClient(async (client) => {
|
|
||||||
await client.del(dn);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {ServiceAccount};
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecret — a secret the owner has published to the shared namespace so it
|
||||||
|
// can be shared with other users and/or downstream apps.
|
||||||
|
//
|
||||||
|
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
|
||||||
|
// never in the DB. This row is metadata only (owner + slug + description) and is
|
||||||
|
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
|
||||||
|
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
|
||||||
|
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
|
||||||
|
// edited to add `read` on the exact shared path (see vault_broker.js — policy
|
||||||
|
// content is parsed live at token use, so a grant takes effect immediately with
|
||||||
|
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
|
||||||
|
//
|
||||||
|
// `slug` is unique and immutable in practice — it is embedded in the shared path
|
||||||
|
// and in grantee policy rules, so changing it would require rewriting policies.
|
||||||
|
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
|
||||||
|
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
|
||||||
|
// ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class SharedSecret extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
|
||||||
|
// Unique so two owners can't collide on the same shared path.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The publishing user's uid — also the shared path's namespace segment.
|
||||||
|
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Optional human description shown in the Shared tab.
|
||||||
|
description: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
|
||||||
|
static pathFor(ownerUid, slug) {
|
||||||
|
return `shared/${ownerUid}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
path() {
|
||||||
|
return SharedSecret.pathFor(this.ownerUid, this.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug (unique). Returns the row or null.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecret };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
|
||||||
|
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
|
||||||
|
// <secretId>".
|
||||||
|
//
|
||||||
|
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
|
||||||
|
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
|
||||||
|
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
|
||||||
|
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
|
||||||
|
// content live at token use, the grant applies to the grantee's existing token
|
||||||
|
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
|
||||||
|
//
|
||||||
|
// granteeType distinguishes the two principal kinds:
|
||||||
|
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
|
||||||
|
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
|
||||||
|
// capability is currently always 'read' (grantees are read-only); the column is
|
||||||
|
// a string so later capabilities could be added without a migration.
|
||||||
|
//
|
||||||
|
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
|
||||||
|
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const GRANTEE_TYPES = ['user', 'app'];
|
||||||
|
const CAPABILITIES = ['read'];
|
||||||
|
|
||||||
|
class SharedSecretGrant extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// FK to SharedSecret.id.
|
||||||
|
secretId: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
|
||||||
|
granteeType: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// The grantee's uid (for 'user') or app name (for 'app').
|
||||||
|
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Access level — 'read' today.
|
||||||
|
capability: { type: 'string', isRequired: true, default: 'read' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All grants for a given grantee (user uid or app name). Used to rebuild the
|
||||||
|
// grantee's policy content so every granted shared path is present/absent.
|
||||||
|
static async listForGrantee(granteeType, granteeId) {
|
||||||
|
return this.list({ where: { granteeType, granteeId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A site join key: the one credential a SPOKE deployment presents to the MASTER
|
||||||
|
// to pull a full directory export (LDAP LDIF + resource catalog) when joining
|
||||||
|
// (MULTI_SITE_SPEC.md). It works like an agent join key — issued once, shown
|
||||||
|
// once, stored hashed, revocable, expirable.
|
||||||
|
//
|
||||||
|
// The master's POST /api/site/export authenticates callers with this key; the
|
||||||
|
// spoke's POST /api/site/join consumes it. The `stj_` prefix distinguishes a
|
||||||
|
// site join key from an agent token / `tjk_` agent join key at a glance.
|
||||||
|
class SiteJoinKey extends Model {
|
||||||
|
static hashKey(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKey() {
|
||||||
|
return 'stj_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented key to a usable site join key, or null. Expiry and
|
||||||
|
// revocation are enforced here so no caller can forget one.
|
||||||
|
static async authenticate(rawKey) {
|
||||||
|
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
const matches = await this.list({ where: { keyHash } });
|
||||||
|
const key = matches && matches[0];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.revoked) return null;
|
||||||
|
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue({ label, createdBy, expiresInDays }) {
|
||||||
|
const raw = this.generateKey();
|
||||||
|
const key = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: label || 'default',
|
||||||
|
keyHash: this.hashKey(raw),
|
||||||
|
keyPrefix: raw.slice(0, 12),
|
||||||
|
revoked: false,
|
||||||
|
created_by: createdBy || null,
|
||||||
|
created_on: Math.floor(Date.now() / 1000),
|
||||||
|
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||||
|
use_count: 0
|
||||||
|
});
|
||||||
|
return { key, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
label: { type: 'string', isRequired: true },
|
||||||
|
keyHash: { type: 'string', isRequired: true },
|
||||||
|
keyPrefix: { type: 'string' },
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
expires_on: { type: 'integer' },
|
||||||
|
use_count: { type: 'integer', default: 0 },
|
||||||
|
last_used_on: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.keyHash;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SiteJoinKey };
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A spoke known to THIS node while it's acting as master — the registry that
|
||||||
|
// makes live replication possible. A spoke registers itself here (POST
|
||||||
|
// /api/site/spokes, authenticated by the same join key it used to join)
|
||||||
|
// right after adopting the master's export, handing over its own reachable
|
||||||
|
// endpoint. In return it's issued a `pushToken`: a shared secret the master
|
||||||
|
// then presents on every future POST <spoke endpoint>/api/site/resync call.
|
||||||
|
//
|
||||||
|
// This is a DIFFERENT credential direction than SiteJoinKey: a join key is
|
||||||
|
// presented TO the master and only ever needs to be verified (so it's stored
|
||||||
|
// hashed, like a password). pushToken is presented BY the master, repeatedly,
|
||||||
|
// so it has to be retrievable here -- there is no getting around storing it
|
||||||
|
// in plaintext on the master, the same way Webhook.secret is (see
|
||||||
|
// services/webhook_emitter.js) for the same reason (an HMAC/bearer credential
|
||||||
|
// the sender must keep re-presenting, not a one-time secret only ever
|
||||||
|
// verified).
|
||||||
|
class SiteSpoke extends Model {
|
||||||
|
static generatePushToken() {
|
||||||
|
return crypto.randomBytes(24).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
endpoint: { type: 'string', isRequired: true, unique: true },
|
||||||
|
siteSlug: { type: 'string' },
|
||||||
|
pushToken: { type: 'string', isRequired: true },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
last_seen_on: { type: 'integer' },
|
||||||
|
// No-inbound relay (MULTI_SITE_SPEC.md): a spoke with no public IP of
|
||||||
|
// its own reports its WG mesh IP + the public hostname it wants
|
||||||
|
// reached at; the master then best-effort creates a matching relay
|
||||||
|
// route on its own theta-proxy (utils/proxy_client.js). relayNote
|
||||||
|
// records what happened for visibility in the UI -- this automation
|
||||||
|
// is optional/best-effort, never a join requirement.
|
||||||
|
noInbound: { type: 'boolean', default: false },
|
||||||
|
meshIp: { type: 'string' },
|
||||||
|
publicHost: { type: 'string' },
|
||||||
|
relayNote: { type: 'string' },
|
||||||
|
// OpenLDAP multi-master replication (docs/replication.md): a unique
|
||||||
|
// small integer this spoke's slapd.conf ServerID must use. Assigned
|
||||||
|
// once at registration (see api_site.js's nextFreeLdapServerId),
|
||||||
|
// reused on re-registration -- a spoke that re-registers after a
|
||||||
|
// restart must not get bumped to a new ID, same reasoning as
|
||||||
|
// jump-host's meshIndex. The master reserves 1 for itself, never
|
||||||
|
// assigned here.
|
||||||
|
ldapServerId: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.pushToken;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SiteSpoke };
|
||||||
@@ -10,6 +10,26 @@ function toE164Digits(number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function send(to, message) {
|
async function send(to, message) {
|
||||||
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
|
const registry = require('../services/plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
|
||||||
|
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
|
||||||
|
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
|
||||||
|
// this sender, so SMS delivery never worked at all: not the test button, not
|
||||||
|
// OTP-by-SMS, not notifications. It failed before it could even fall back to
|
||||||
|
// the direct VoIP.ms path below.
|
||||||
|
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
|
||||||
|
if (instances.length > 0) {
|
||||||
|
const inst = instances[0];
|
||||||
|
const manifest = registry.getManifest(inst.pluginType);
|
||||||
|
if (manifest && manifest.sendMessage) {
|
||||||
|
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
|
||||||
|
const config = { ...inst.config, ...secrets };
|
||||||
|
return manifest.sendMessage(config, { to, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_username: conf.username,
|
api_username: conf.username,
|
||||||
api_password: conf.password,
|
api_password: conf.password,
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
|
const crypto = require('crypto');
|
||||||
|
const UUID = () => crypto.randomUUID();
|
||||||
|
|
||||||
|
class Token extends Model {
|
||||||
class Token extends Table{
|
static adapterName = 'redis';
|
||||||
static _key = 'token';
|
static fields = {
|
||||||
static _keyMap = {
|
token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
|
||||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
|
||||||
'created_on': {default: function(){return (new Date).getTime()}},
|
created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
|
||||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
updated_on: { type: 'integer', default: function(){return (new Date).getTime()}, always: true },
|
||||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
is_valid: { default: true, type: 'boolean' }
|
||||||
'is_valid': {default: true, type: 'boolean'},
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(...args){
|
|
||||||
super(...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async check(){
|
async check(){
|
||||||
@@ -27,12 +23,10 @@ class Token extends Table{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Token.register();
|
|
||||||
|
|
||||||
class AuthToken extends Token{
|
class AuthToken extends Token{
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(data){
|
static async create(data){
|
||||||
@@ -41,11 +35,10 @@ class AuthToken extends Token{
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthToken.register();
|
|
||||||
|
|
||||||
class InviteToken extends Token{
|
class InviteToken extends Token{
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
||||||
mail: {default: '__NONE__', type: 'string'},
|
mail: {default: '__NONE__', type: 'string'},
|
||||||
mail_token: {default: '__NONE__', type: 'string'},
|
mail_token: {default: '__NONE__', type: 'string'},
|
||||||
@@ -67,14 +60,13 @@ class InviteToken extends Token{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InviteToken.register();
|
|
||||||
|
|
||||||
class ImpersonationToken extends Token {
|
class ImpersonationToken extends Token {
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
||||||
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'},
|
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'integer'},
|
||||||
}
|
}
|
||||||
|
|
||||||
get isExpired() {
|
get isExpired() {
|
||||||
@@ -86,42 +78,48 @@ class ImpersonationToken extends Token {
|
|||||||
return this.create(data);
|
return this.create(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImpersonationToken.register();
|
|
||||||
|
|
||||||
class PasswordResetToken extends Token {}
|
class PasswordResetToken extends Token {}
|
||||||
PasswordResetToken.register();
|
|
||||||
|
|
||||||
class OtpToken extends Token {
|
class OtpToken extends Token {
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...Token._keyMap,
|
...Token.fields,
|
||||||
uid: {isRequired: true, type: 'string'},
|
uid: {isRequired: true, type: 'string'},
|
||||||
code: {isRequired: true, type: 'string'},
|
code: {isRequired: true, type: 'string'},
|
||||||
method: {isRequired: true, type: 'string'},
|
method: {isRequired: true, type: 'string'},
|
||||||
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'},
|
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'integer'},
|
||||||
};
|
};
|
||||||
|
|
||||||
get isExpired() {
|
get isExpired() {
|
||||||
return (new Date).getTime() > this.expires_at;
|
return (new Date).getTime() > this.expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
|
|
||||||
static async issue(uid, method) {
|
static async issue(uid, method) {
|
||||||
const existing = await this.listDetail({uid});
|
const existing = await this.list({where: {uid}});
|
||||||
for (const t of existing) {
|
for (const t of existing) {
|
||||||
if (t.is_valid) await t.update({is_valid: false});
|
if (t.is_valid) await t.update({is_valid: false});
|
||||||
}
|
}
|
||||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
const code = String(crypto.randomInt(100000, 1000000));
|
||||||
return this.create({uid, code, method, created_by: uid});
|
return this.create({uid, code, method, created_by: uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async verify(uid, code) {
|
static async verify(uid, code) {
|
||||||
const tokens = await this.listDetail({uid});
|
const tokens = await this.list({where: {uid}});
|
||||||
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
await match.update({is_valid: false});
|
await match.update({is_valid: false});
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OtpToken.register();
|
class ServiceToken extends Token {
|
||||||
|
static fields = {
|
||||||
|
...Token.fields,
|
||||||
|
resource_id: {isRequired: true, type: 'string'}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
|
static async issue(resource_id, created_by) {
|
||||||
|
return this.create({resource_id, created_by});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const Table = require('.');
|
||||||
|
|
||||||
|
// Terms-of-Service text, editable by an admin at runtime (see routes/tos.js
|
||||||
|
// + the Dashboard's "Terms of Service" card) instead of being baked into the
|
||||||
|
// repo. A singleton row -- always keyed 'current' -- rather than a UUID like
|
||||||
|
// the other Redis models here, since there's only ever one live ToS.
|
||||||
|
class Tos extends Table {
|
||||||
|
static _key = 'name';
|
||||||
|
static _keyMap = {
|
||||||
|
name: {default: 'current', type: 'string'},
|
||||||
|
content: {isRequired: true, type: 'string'},
|
||||||
|
updated_by: {isRequired: true, type: 'string'},
|
||||||
|
updated_on: {default: () => Date.now()},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the live row, seeding it from the bundled tos.md template the
|
||||||
|
// first time this is ever called on a deployment (so upgrading an
|
||||||
|
// existing install doesn't start with a blank ToS).
|
||||||
|
static async getCurrent() {
|
||||||
|
try {
|
||||||
|
return await this.get('current');
|
||||||
|
} catch (error) {
|
||||||
|
const content = fs.readFileSync(path.join(__dirname, '../../tos.md'), 'utf8');
|
||||||
|
return this.create({name: 'current', content, updated_by: 'system'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tos.register();
|
||||||
|
|
||||||
|
module.exports = {Tos};
|
||||||
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
|||||||
const {Group} = require('./group_ldap');
|
const {Group} = require('./group_ldap');
|
||||||
const {UserVerification} = require('./verification');
|
const {UserVerification} = require('./verification');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
|
||||||
|
// wrappers below preserve this file's no-arg call signatures (makeClient() /
|
||||||
|
// withClient(fn)) so no call site changes; sso's makeClient passes no
|
||||||
|
// tlsOptions, which the shared client forwards as undefined — identical to the
|
||||||
|
// previous `new Client({ url: conf.url })`.
|
||||||
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
|
const escapeLDAPDNValue = escapeDN;
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
function hashPasswordSSHA512(password) {
|
||||||
const salt = crypto.randomBytes(8);
|
const salt = crypto.randomBytes(8);
|
||||||
@@ -23,26 +31,11 @@ const cache = new LRUCache({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to escape LDAP filter values (crucial for security)
|
|
||||||
function escapeLDAPSearchValue(val) {
|
|
||||||
return val.replace(/\\/g, '\\5c')
|
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute the next available uid/gidNumber: the highest existing value below
|
// Compute the next available uid/gidNumber: the highest existing value below
|
||||||
@@ -72,7 +65,8 @@ async function addPosixGroup(client, data){
|
|||||||
|
|
||||||
data.gidNumber = nextPosixId(groups, 'gidNumber');
|
data.gidNumber = nextPosixId(groups, 'gidNumber');
|
||||||
|
|
||||||
await client.add(`cn=${data.cn},${conf.groupBase}`, {
|
const safeCn = escapeLDAPDNValue(data.cn);
|
||||||
|
await client.add(`cn=${safeCn},${conf.groupBase}`, {
|
||||||
cn: data.cn,
|
cn: data.cn,
|
||||||
gidNumber: data.gidNumber,
|
gidNumber: data.gidNumber,
|
||||||
objectclass: [ 'posixGroup', 'top' ]
|
objectclass: [ 'posixGroup', 'top' ]
|
||||||
@@ -94,6 +88,7 @@ async function addPosixAccount(client, data){
|
|||||||
|
|
||||||
data.uidNumber = nextPosixId(people, 'uidNumber');
|
data.uidNumber = nextPosixId(people, 'uidNumber');
|
||||||
|
|
||||||
|
const safeCn = escapeLDAPDNValue(data.cn);
|
||||||
const entry = {
|
const entry = {
|
||||||
cn: data.cn,
|
cn: data.cn,
|
||||||
sn: data.sn,
|
sn: data.sn,
|
||||||
@@ -103,7 +98,6 @@ async function addPosixAccount(client, data){
|
|||||||
givenName: data.givenName,
|
givenName: data.givenName,
|
||||||
loginShell: data.loginShell,
|
loginShell: data.loginShell,
|
||||||
homeDirectory: data.homeDirectory,
|
homeDirectory: data.homeDirectory,
|
||||||
userPassword: data.userPassword,
|
|
||||||
description: data.description || ' ',
|
description: data.description || ' ',
|
||||||
sudoHost: 'ALL',
|
sudoHost: 'ALL',
|
||||||
sudoCommand: 'ALL',
|
sudoCommand: 'ALL',
|
||||||
@@ -131,7 +125,24 @@ async function addPosixAccount(client, data){
|
|||||||
entry.dateOfBirth = data.dob;
|
entry.dateOfBirth = data.dob;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
|
if (data.location) {
|
||||||
|
entry.l = data.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
// userPassword is optional -- a service account with no password set
|
||||||
|
// simply can't bind (no special enforcement needed, that's the default
|
||||||
|
// LDAP simple-bind behavior for an entry lacking the attribute).
|
||||||
|
if (data.userPassword) {
|
||||||
|
entry.userPassword = data.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
|
||||||
|
// every account gets at least the DN of whoever created it.
|
||||||
|
if (data.manager && [].concat(data.manager).length) {
|
||||||
|
entry.manager = [].concat(data.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.add(`cn=${safeCn},${conf.userBase}`, entry);
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -151,11 +162,14 @@ async function addLdapUser(client, data){
|
|||||||
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
||||||
}
|
}
|
||||||
data.cn = data.uid;
|
data.cn = data.uid;
|
||||||
data.loginShell = '/bin/bash';
|
data.loginShell = data.loginShell || '/bin/bash';
|
||||||
data.homeDirectory= `/home/${data.uid}`;
|
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
|
||||||
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
if (data.userPassword) {
|
||||||
|
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
||||||
|
} else {
|
||||||
|
delete data.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('addLdapUser', data)
|
|
||||||
group = await addPosixGroup(client, data);
|
group = await addPosixGroup(client, data);
|
||||||
data = await addPosixAccount(client, group);
|
data = await addPosixAccount(client, group);
|
||||||
|
|
||||||
@@ -190,10 +204,19 @@ const user_parse = function(data){
|
|||||||
data.username = data[conf.userNameAttribute]
|
data.username = data[conf.userNameAttribute]
|
||||||
data.userPassword = undefined;
|
data.userPassword = undefined;
|
||||||
}
|
}
|
||||||
|
data.location = data.l ? String(data.l) : '';
|
||||||
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
||||||
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
||||||
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
||||||
|
|
||||||
|
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
|
||||||
|
// overlay) are both multi-valued; ldapts returns a bare string for a
|
||||||
|
// single value and an array for multiple -- normalize both to always be
|
||||||
|
// an array, or app-base.js's `for(let group of user.memberOf)` silently
|
||||||
|
// iterates a single DN string character-by-character instead of once.
|
||||||
|
data.manager = [].concat(data.manager || []).filter(Boolean);
|
||||||
|
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +265,8 @@ User.listDetail = async function(){
|
|||||||
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
||||||
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
||||||
|
|
||||||
|
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
|
||||||
|
|
||||||
const users = await Promise.all(searchEntries.map(async (entry) => {
|
const users = await Promise.all(searchEntries.map(async (entry) => {
|
||||||
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
||||||
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
||||||
@@ -269,6 +294,10 @@ User.listDetail = async function(){
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||||
|
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
|
||||||
|
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
|
||||||
|
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
@@ -324,6 +353,13 @@ User.get = async function(data, key) {
|
|||||||
|
|
||||||
const verif = await UserVerification.getOrCreate(obj.uid);
|
const verif = await UserVerification.getOrCreate(obj.uid);
|
||||||
|
|
||||||
|
// Same membership check as User.listDetail() -- see the comment there.
|
||||||
|
try{
|
||||||
|
const svcGroup = await Group.get('app_sso_service_account');
|
||||||
|
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
||||||
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
|
}catch(error){ obj.isServiceAccount = ''; }
|
||||||
|
|
||||||
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
|
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
|
||||||
if (isLegacyMD5 && !verif.password_must_change) {
|
if (isLegacyMD5 && !verif.password_must_change) {
|
||||||
await verif.update({ password_must_change: true });
|
await verif.update({ password_must_change: true });
|
||||||
@@ -421,7 +457,7 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let editableFeilds = ['mobile', 'description'];
|
let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
|
||||||
|
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
for(let field of editableFeilds){
|
for(let field of editableFeilds){
|
||||||
@@ -440,6 +476,19 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(data.sshPublicKey){
|
if(data.sshPublicKey){
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if(e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'replace',
|
operation: 'replace',
|
||||||
@@ -469,6 +518,31 @@ User.update = async function(data){
|
|||||||
]);
|
]);
|
||||||
this.dateOfBirth = data.dateOfBirth;
|
this.dateOfBirth = data.dateOfBirth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(data.location !== undefined){
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'replace',
|
||||||
|
modification: new Attribute({ type: 'l', values: [data.location] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.location = data.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(data.manager !== undefined){
|
||||||
|
// Client sends uids; resolve each to a DN before writing --
|
||||||
|
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
|
||||||
|
const uids = [].concat(data.manager || []).filter(Boolean);
|
||||||
|
const managers = await Promise.all(uids.map(uid => User.get(uid)));
|
||||||
|
const dns = managers.map(u => u.dn);
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'replace',
|
||||||
|
modification: new Attribute({ type: 'manager', values: dns }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.manager = dns;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
|
||||||
@@ -537,6 +611,12 @@ User.addByInvite = async function(data){
|
|||||||
|
|
||||||
data.mail = token.mail;
|
data.mail = token.mail;
|
||||||
|
|
||||||
|
// Default manager: whoever sent the invite.
|
||||||
|
try {
|
||||||
|
const inviter = await this.get(token.created_by);
|
||||||
|
data.manager = [inviter.dn];
|
||||||
|
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
|
||||||
|
|
||||||
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
|
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
|
||||||
if (!data.uid || !suggestions.includes(data.uid)) {
|
if (!data.uid || !suggestions.includes(data.uid)) {
|
||||||
const err = new Error('Invalid username selection');
|
const err = new Error('Invalid username selection');
|
||||||
@@ -693,7 +773,7 @@ User.setActive = async function(active) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
|
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -708,7 +788,7 @@ User.setActive = async function(active) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||||
this.isActive = active ? 'active' : '';
|
this.isActive = active ? 'active' : '';
|
||||||
this.isInactive = active ? '' : 'inactive';
|
this.isInactive = active ? '' : 'inactive';
|
||||||
cache.clear();
|
cache.clear();
|
||||||
@@ -720,6 +800,19 @@ User.addSSHkey = async function(data) {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(user.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if (e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(user.dn, [
|
await client.modify(user.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'add',
|
operation: 'add',
|
||||||
@@ -739,6 +832,53 @@ User.addSSHkey = async function(data) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every user gets a personal Unix group of the same name at creation (see
|
||||||
|
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
|
||||||
|
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
|
||||||
|
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
|
||||||
|
function personalGroupDN(uid){
|
||||||
|
return `cn=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
User.getPersonalGroupMembers = async function(uid) {
|
||||||
|
try {
|
||||||
|
return await withClient(async (client) => {
|
||||||
|
const res = await client.search(personalGroupDN(uid), {
|
||||||
|
scope: 'base',
|
||||||
|
filter: '(objectClass=posixGroup)',
|
||||||
|
attributes: ['memberUid'],
|
||||||
|
});
|
||||||
|
const entry = res.searchEntries[0];
|
||||||
|
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
|
||||||
|
});
|
||||||
|
} catch(error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
User.addPersonalGroupMember = async function(uid, memberUid) {
|
||||||
|
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(personalGroupDN(uid), [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
User.removePersonalGroupMember = async function(uid, memberUid) {
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(personalGroupDN(uid), [
|
||||||
|
new Change({
|
||||||
|
operation: 'delete',
|
||||||
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
User.invite = async function(data = {}){
|
User.invite = async function(data = {}){
|
||||||
try{
|
try{
|
||||||
let token = await InviteToken.create({
|
let token = await InviteToken.create({
|
||||||
@@ -759,8 +899,21 @@ User.invite = async function(data = {}){
|
|||||||
|
|
||||||
User.login = async function(data){
|
User.login = async function(data){
|
||||||
try{
|
try{
|
||||||
|
if (!data.uid && !data.username) {
|
||||||
|
let error = new Error('Invalid Credentials, login failed.');
|
||||||
|
error.name = 'LDAPLoginFailed';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
let user = await this.get(data.uid || data.username);
|
let user = await this.get(data.uid || data.username);
|
||||||
|
|
||||||
|
if (user.pwdAccountLockedTime) {
|
||||||
|
let error = new Error('Invalid Credentials, login failed.');
|
||||||
|
error.name = 'LDAPLoginFailed';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const loginClient = makeClient();
|
const loginClient = makeClient();
|
||||||
try {
|
try {
|
||||||
await loginClient.bind(user.dn, data.password);
|
await loginClient.bind(user.dn, data.password);
|
||||||
@@ -771,7 +924,7 @@ User.login = async function(data){
|
|||||||
return user;
|
return user;
|
||||||
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error("USER LOGIN error:", error);
|
console.error("USER LOGIN error:", error.name, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
|
||||||
|
// from the vault UI (Apps tab), so sso can keep the token alive.
|
||||||
|
//
|
||||||
|
// The token itself is shown ONCE at mint and never stored (a stolen accessor
|
||||||
|
// cannot authenticate — it can only look up, renew, or revoke its token, and
|
||||||
|
// only the sso broker's policy grants those endpoints). App tokens are minted
|
||||||
|
// through the sso-app role as PERIODIC tokens: they live forever, but only if
|
||||||
|
// something renews them inside every period window. That something is sso's
|
||||||
|
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
|
||||||
|
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
|
||||||
|
// stays valid as long as sso itself is running, with no renewal code needed in
|
||||||
|
// the downstream app.
|
||||||
|
//
|
||||||
|
// One row per app name: re-minting an app's token revokes the previous token
|
||||||
|
// via its accessor (no zombie credentials) and replaces the row.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class VaultAppToken extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// The external app's name — also its policy (app-<name>) and KV namespace
|
||||||
|
// (secret/apps/<name>/). Unique: one live token per app.
|
||||||
|
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The minted token's accessor (renew/revoke handle, cannot authenticate).
|
||||||
|
accessor: { type: 'string', isRequired: true, max: 128 },
|
||||||
|
// Renewal bookkeeping, updated by the renewal loop.
|
||||||
|
lastRenewedAt: { type: 'integer' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
static async getByName(name) {
|
||||||
|
const rows = await this.list({ where: { name } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VaultAppToken };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class Webhook extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
name: { type: 'string', isRequired: true },
|
||||||
|
url: { type: 'string', isRequired: true },
|
||||||
|
events: { type: 'json', default: [] }, // e.g. ['discovery.new_device', 'resource.updated']
|
||||||
|
secret: { type: 'string' },
|
||||||
|
isActive: { type: 'boolean', default: true },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Webhook };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-theta-directory",
|
||||||
"version": "1.1.0",
|
"version": "2.8.0",
|
||||||
"private": true,
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "NODE_ENV=test jest --runInBand --forceExit --testTimeout=15000"
|
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js tests/reconciler.test.js tests/nmap_plugin.test.js tests/jump_client.test.js tests/ldap_replication.test.js --forceExit"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
@@ -23,26 +23,39 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.1.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.1",
|
||||||
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.0.1",
|
"http-proxy-middleware": "^2.0.10",
|
||||||
"jquery": "^3.7.1",
|
"ioredis": "^6.0.0",
|
||||||
|
"jq-repeat": "^2.2.0",
|
||||||
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^0.4.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs
|
||||||
|
index c7646a4..411b56f 100644
|
||||||
|
--- a/nodejs/views/directory.ejs
|
||||||
|
+++ b/nodejs/views/directory.ejs
|
||||||
|
@@ -3,7 +3,26 @@
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
- <div class="card shadow">
|
||||||
|
+ <ul class="nav nav-tabs mb-3" id="directoryTabs" role="tablist">
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
|
||||||
|
+ <i class="fa-solid fa-server"></i> Directory
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
|
||||||
|
+ <i class="fa-solid fa-network-wired"></i> Discovery
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
|
||||||
|
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ </ul>
|
||||||
|
+ <div class="tab-content" id="directoryTabsContent">
|
||||||
|
+ <div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-server"></i> Directory Management
|
||||||
|
@@ -74,6 +93,148 @@
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
+
|
||||||
|
+ <!-- Discovery Tab Pane -->
|
||||||
|
+ <div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
+ <div>
|
||||||
|
+ <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
|
||||||
|
+ </div>
|
||||||
|
+ <div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
+ <input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
|
||||||
|
+ <select id="discovery-filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderDiscoveryTable()" style="width: 150px;">
|
||||||
|
+ <option value="unmanaged">Unmanaged Only</option>
|
||||||
|
+ <option value="managed">Managed Only</option>
|
||||||
|
+ <option value="all">All Resources</option>
|
||||||
|
+ </select>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
+ <div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
+ <i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
|
||||||
|
+ <a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="table-responsive">
|
||||||
|
+ <table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
+ <thead class="table-light">
|
||||||
|
+ <tr>
|
||||||
|
+ <th class="ps-3">Name / Source</th>
|
||||||
|
+ <th>Type</th>
|
||||||
|
+ <th>IP Address</th>
|
||||||
|
+ <th>Status</th>
|
||||||
|
+ <th class="text-end pe-3">Actions</th>
|
||||||
|
+ </tr>
|
||||||
|
+ </thead>
|
||||||
|
+ <tbody id="discovery-list" jq-repeat="discoveryResources">
|
||||||
|
+ <tr id="discovery-row-{{slug}}">
|
||||||
|
+ <td class="ps-3">
|
||||||
|
+ <div class="fw-bold">{{name}}</div>
|
||||||
|
+ <div class="text-muted small">
|
||||||
|
+ <i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
|
+ </div>
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ <span class="badge bg-secondary">{{kind}}</span>
|
||||||
|
+ {{#metadata.subType}}
|
||||||
|
+ <span class="badge bg-light text-dark border">{{metadata.subType}}</span>
|
||||||
|
+ {{/metadata.subType}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
+ {{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
||||||
|
+ {{#metadata.interfaces.length}}
|
||||||
|
+ <div class="mt-1 small text-muted">
|
||||||
|
+ {{#metadata.interfaces}}
|
||||||
|
+ <div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
|
||||||
|
+ {{/metadata.interfaces}}
|
||||||
|
+ </div>
|
||||||
|
+ {{/metadata.interfaces.length}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#metadata.managed}}
|
||||||
|
+ <span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ {{^metadata.managed}}
|
||||||
|
+ <span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ </td>
|
||||||
|
+ <td class="text-end pe-3">
|
||||||
|
+ {{^metadata.managed}}
|
||||||
|
+ <button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||||
|
+ <i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||||
|
+ </button>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ {{#metadata.managed}}
|
||||||
|
+ <button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||||
|
+ Promoted
|
||||||
|
+ </button>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ <tbody id="discovery-empty-state" style="display: none;">
|
||||||
|
+ <tr>
|
||||||
|
+ <td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
+ <i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
|
||||||
|
+ <h5>No resources found</h5>
|
||||||
|
+ <p>Check your filters or ensure the discovery agents are running.</p>
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ </table>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <!-- Plugins Tab Pane -->
|
||||||
|
+ <div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
+ <div>
|
||||||
|
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
+ <i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/plugins">Learn how to make and use custom plugins</a>.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="table-responsive">
|
||||||
|
+ <table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
+ <thead class="table-light">
|
||||||
|
+ <tr>
|
||||||
|
+ <th class="ps-3">Plugin Name</th>
|
||||||
|
+ <th>Cron Schedule</th>
|
||||||
|
+ <th>Status</th>
|
||||||
|
+ <th>Actions</th>
|
||||||
|
+ </tr>
|
||||||
|
+ </thead>
|
||||||
|
+ <tbody id="plugins-list" jq-repeat="plugins">
|
||||||
|
+ <tr>
|
||||||
|
+ <td class="ps-3 fw-bold">{{name}}</td>
|
||||||
|
+ <td><input type="text" class="form-control form-control-sm font-monospace" id="cron-{{name}}" value="{{cron}}" style="max-width: 150px;"></td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||||
|
+ {{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ <button class="btn btn-sm btn-outline-primary" onclick="updatePlugin('{{name}}')" title="Save Schedule">Save</button>
|
||||||
|
+ {{#enabled}}<button class="btn btn-sm btn-outline-danger" onclick="togglePlugin('{{name}}', false)">Disable</button>{{/enabled}}
|
||||||
|
+ {{^enabled}}<button class="btn btn-sm btn-outline-success" onclick="togglePlugin('{{name}}', true)">Enable</button>{{/enabled}}
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ <tbody id="plugins-empty-state" style="display: none;">
|
||||||
|
+ <tr>
|
||||||
|
+ <td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
+ No plugins configured.
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ </table>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -413,14 +574,144 @@
|
||||||
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||||
|
if (parentEdge) {
|
||||||
|
r.parentId = parentEdge.parentId;
|
||||||
|
- const parent = resourcesById[parentEdge.parentId];
|
||||||
|
+ const parent = resourcesById[parentEdge.parentId];
|
||||||
|
if (parent) r.hostName = parent.name;
|
||||||
|
}
|
||||||
|
rawResources.push(r);
|
||||||
|
}
|
||||||
|
+ function openAddModal(parent_id, kind) {
|
||||||
|
+ if(parent_id){
|
||||||
|
+ $('#newResourceParent').val(parent_id);
|
||||||
|
+ $('#newResourceKind').val(kind);
|
||||||
|
+ var currentLabel = "Resource";
|
||||||
|
+ if(kind === 'Host'){ currentLabel = 'Host'; }
|
||||||
|
+ else if(kind === 'Site'){ currentLabel = 'Site'; }
|
||||||
|
+
|
||||||
|
+ $('#newResourceLabel').text('Add Child ' + currentLabel);
|
||||||
|
+ }else{
|
||||||
|
+ $('#newResourceParent').val('');
|
||||||
|
+ $('#newResourceKind').val('Host');
|
||||||
|
+ $('#newResourceLabel').text('Add Resource');
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Clear input
|
||||||
|
+ $('#newResourceName').val('');
|
||||||
|
+ $('#addResourceModal').modal('show');
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // --- DISCOVERY SCRIPTS ---
|
||||||
|
+ let allDiscoveryResources = [];
|
||||||
|
+
|
||||||
|
+ function loadDiscoveryResources() {
|
||||||
|
+ app.api.get('discovery/resources', function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ $('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ allDiscoveryResources = res.results || [];
|
||||||
|
+ renderDiscoveryTable();
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function renderDiscoveryTable() {
|
||||||
|
+ const search = $('#discovery-search-filter').val().toLowerCase();
|
||||||
|
+ const managedFilter = $('#discovery-filter-managed').val();
|
||||||
|
+
|
||||||
|
+ const filtered = allDiscoveryResources.filter(r => {
|
||||||
|
+ if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
+ const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
+ if(managedFilter === 'managed' && !isManaged) return false;
|
||||||
|
+ if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||||
|
+ return true;
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ $.scope.discoveryResources.empty();
|
||||||
|
+ for(const r of filtered) {
|
||||||
|
+ $.scope.discoveryResources.push(r);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if(filtered.length === 0) {
|
||||||
|
+ $('#discovery-list').hide();
|
||||||
|
+ $('#discovery-empty-state').show();
|
||||||
|
+ } else {
|
||||||
|
+ $('#discovery-list').show();
|
||||||
|
+ $('#discovery-empty-state').hide();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function promoteResource(slug) {
|
||||||
|
+ if(!confirm("Are you sure you want to promote this resource? This will generate SSO LDAP groups for it.")) return;
|
||||||
|
+ app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ alert("Error promoting resource: " + (err.message || err));
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ const resource = allDiscoveryResources.find(r => r.slug === slug);
|
||||||
|
+ if(resource) {
|
||||||
|
+ resource.metadata = resource.metadata || {};
|
||||||
|
+ resource.metadata.managed = true;
|
||||||
|
+ }
|
||||||
|
+ $('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
|
||||||
|
+ renderDiscoveryTable();
|
||||||
|
+ renderTable(); // Also update directory tab
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // --- PLUGINS SCRIPTS ---
|
||||||
|
+ function loadPlugins() {
|
||||||
|
+ app.api.get('plugins', function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ alert("Error loading plugins: " + (err.message || err));
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ const plugins = res.results || {};
|
||||||
|
+ const pluginNames = Object.keys(plugins);
|
||||||
|
|
||||||
|
- renderTable();
|
||||||
|
+ $.scope.plugins.empty();
|
||||||
|
+ if(pluginNames.length === 0) {
|
||||||
|
+ $('#plugins-list').hide();
|
||||||
|
+ $('#plugins-empty-state').show();
|
||||||
|
+ } else {
|
||||||
|
+ pluginNames.forEach(name => {
|
||||||
|
+ const config = plugins[name];
|
||||||
|
+ $.scope.plugins.push({
|
||||||
|
+ name: name,
|
||||||
|
+ cron: config.cron || '',
|
||||||
|
+ enabled: config.enabled
|
||||||
|
+ });
|
||||||
|
+ });
|
||||||
|
+ $('#plugins-list').show();
|
||||||
|
+ $('#plugins-empty-state').hide();
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
|
||||||
|
+ function updatePlugin(name) {
|
||||||
|
+ const cron = $('#cron-' + name).val();
|
||||||
|
+ app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
|
||||||
|
+ if(err) { alert("Failed to save: " + err.message); return; }
|
||||||
|
+ alert("Saved schedule successfully.");
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function togglePlugin(name, enable) {
|
||||||
|
+ app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
|
||||||
|
+ if(err) { alert("Failed to toggle: " + err.message); return; }
|
||||||
|
+ loadPlugins();
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ $(document).ready(function(){
|
||||||
|
+ renderTable();
|
||||||
|
+ loadDiscoveryResources();
|
||||||
|
+ loadPlugins();
|
||||||
|
+
|
||||||
|
+ // Auto-open modal if hash is present
|
||||||
|
+ if(window.location.hash && window.location.hash.startsWith('#modal-')) {
|
||||||
|
+ const slug = window.location.hash.replace('#modal-', '');
|
||||||
|
+ setTimeout(() => openEditModal(slug), 500);
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
|
||||||
|
// input accepts a free-typed uid whether or not the list ever arrives.
|
||||||
|
loadDirectoryUsers().then(function(users) {
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
type: 'docker',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Docker Daemon',
|
||||||
|
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
||||||
|
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
|
||||||
|
// Containers in this compose project are the stack's own. They are already
|
||||||
|
// represented in the catalog as services, so they are recorded as managed
|
||||||
|
// and linked to the service they implement instead of arriving as
|
||||||
|
// unmanaged strangers a fresh install has to triage.
|
||||||
|
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
||||||
|
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
if (!config.socketPath && !config.tcpHost) {
|
||||||
|
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const isTcp = !!config.tcpHost;
|
||||||
|
|
||||||
|
const requestOptions = {
|
||||||
|
path: '/containers/json',
|
||||||
|
method: 'GET'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTcp) {
|
||||||
|
const url = new URL(config.tcpHost);
|
||||||
|
requestOptions.host = url.hostname;
|
||||||
|
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
|
||||||
|
requestOptions.protocol = url.protocol;
|
||||||
|
} else {
|
||||||
|
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(requestOptions, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containers = JSON.parse(body);
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
const stackProject = (config.stackProject || '').trim();
|
||||||
|
const hostSlug = (config.hostSlug || '').trim();
|
||||||
|
|
||||||
|
for (const c of containers) {
|
||||||
|
const labels = c.Labels || {};
|
||||||
|
const composeProject = labels['com.docker.compose.project'] || '';
|
||||||
|
const composeService = labels['com.docker.compose.service'] || '';
|
||||||
|
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
||||||
|
|
||||||
|
// A container id changes every time the container is recreated,
|
||||||
|
// so an id-derived slug made `docker compose up` mint a brand-new
|
||||||
|
// resource on every deploy and orphan the previous one. Prefer
|
||||||
|
// identifiers that survive a recreate: the compose project+service
|
||||||
|
// it belongs to, else its name.
|
||||||
|
const stableKey = composeProject && composeService
|
||||||
|
? `${composeProject}-${composeService}`
|
||||||
|
: (name || c.Id.substring(0, 12));
|
||||||
|
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
|
||||||
|
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||||
|
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||||
|
|
||||||
|
const isIgnored = /openbao|openboa|bao-renewer/i.test(name) || /openbao|openboa|bao-renewer/i.test(composeService);
|
||||||
|
|
||||||
|
if (isIgnored) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'container',
|
||||||
|
name: composeService || name,
|
||||||
|
slug: slug,
|
||||||
|
metadata: {
|
||||||
|
image: c.Image,
|
||||||
|
state: c.State,
|
||||||
|
status: c.Status,
|
||||||
|
ports: ports,
|
||||||
|
subType: 'docker',
|
||||||
|
composeProject: composeProject || undefined,
|
||||||
|
composeService: composeService || undefined,
|
||||||
|
containerName: name,
|
||||||
|
sourceId: stableKey,
|
||||||
|
ignored: isIgnored ? true : undefined,
|
||||||
|
// Part of the deployment we are running inside: already
|
||||||
|
// accounted for, not something to promote.
|
||||||
|
managed: (isOwnStack || isIgnored) ? true : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach the container to the service it implements when the
|
||||||
|
// catalog already has one under that slug (the bootstrap seeds
|
||||||
|
// `sso-manager`, `proxy`, `jump-host`, … using the same names
|
||||||
|
// compose uses). The reconciler drops an edge whose parent does
|
||||||
|
// not resolve, so an unmatched name is simply not linked.
|
||||||
|
if (isOwnStack && composeService) {
|
||||||
|
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
|
||||||
|
} else if (hostSlug) {
|
||||||
|
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ resources, edges });
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Failed to parse Docker response: ${e.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const nmap = require('node-nmap');
|
||||||
|
nmap.nmapLocation = "nmap"; // default
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
|
||||||
|
// not secret (it's a network range to scan), so it lives in the DB row, not
|
||||||
|
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
|
||||||
|
// the range parses — running a real scan is what `run` does.
|
||||||
|
type: 'nmap',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Nmap Network Scan',
|
||||||
|
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
|
||||||
|
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
|
||||||
|
// only sanity-check shape here — reject anything with shell metacharacters
|
||||||
|
// or whitespace, since node-nmap passes this straight to the nmap binary.
|
||||||
|
if (/\s|[;|&$`<>]/.test(targetRange)) {
|
||||||
|
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
||||||
|
// Pass custom arguments in constructor so node-nmap includes them before spawning nmap process.
|
||||||
|
// -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge)
|
||||||
|
// -sT: TCP connect scan (unprivileged scan compatible with container environments)
|
||||||
|
// -F: fast scan (100 top ports)
|
||||||
|
// --min-rate 100: speed up scan rate
|
||||||
|
const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100'];
|
||||||
|
const scan = new nmap.NmapScan(targetRange, customFlags);
|
||||||
|
|
||||||
|
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
|
||||||
|
|
||||||
|
scan.on('complete', function(data) {
|
||||||
|
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
for (const host of data) {
|
||||||
|
if (!host.ip) continue;
|
||||||
|
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
|
||||||
|
const hostSlug = `nmap-host-${hostId}`;
|
||||||
|
|
||||||
|
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: host.hostname || host.ip,
|
||||||
|
slug: hostSlug,
|
||||||
|
metadata: { interfaces, os: host.osNmap }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (host.openPorts && host.openPorts.length > 0) {
|
||||||
|
for (const port of host.openPorts) {
|
||||||
|
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'service',
|
||||||
|
name: `${port.service} on ${port.port}`,
|
||||||
|
slug: svcSlug,
|
||||||
|
metadata: { port: port.port, protocol: port.protocol }
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: hostSlug, childSlug: svcSlug, relation: 'exposes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve({ resources, edges });
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.on('error', function(error) {
|
||||||
|
// node-nmap's spawn-missing-binary message ("NMAP not found at command
|
||||||
|
// location: nmap") is opaque to an admin reading lastError. Translate
|
||||||
|
// it into something actionable. (The Dockerfile installs nmap in the
|
||||||
|
// app image; this only fires if someone runs outside the container or
|
||||||
|
// strips the package.)
|
||||||
|
var msg = (error && error.message) || String(error);
|
||||||
|
if (/nmap.*not found|command location/i.test(msg)) {
|
||||||
|
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// node-nmap (node_modules/node-nmap/index.js) treats ANY stderr
|
||||||
|
// output from the nmap binary as a fatal scan error -- including
|
||||||
|
// nmap's own benign RTT timing-calibration warnings ("RTTVAR has
|
||||||
|
// grown to over N seconds, decreasing to M"), which it prints
|
||||||
|
// *during* a scan that goes on to complete normally. That means a
|
||||||
|
// scan that actually succeeded (valid XML already sitting in
|
||||||
|
// scan.rawData) got thrown away and reported as a failed run with
|
||||||
|
// zero hosts discovered -- not just a noisy log line. Recover by
|
||||||
|
// manually re-running node-nmap's own XML-parse-then-complete path
|
||||||
|
// (rawDataHandler -> scanComplete -> the 'complete' listener above)
|
||||||
|
// when the "error" is this specific known-benign nmap message and
|
||||||
|
// there's actually output to parse. A genuine XML parse failure
|
||||||
|
// re-emits 'error' with a different message, which falls through to
|
||||||
|
// reject() below same as before -- this only widens the recovery
|
||||||
|
// path, it doesn't swallow real failures.
|
||||||
|
if (/RTTVAR has grown/i.test(msg) && scan.rawData) {
|
||||||
|
scan.rawDataHandler(scan.rawData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.startScan();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||||
|
// this references module.exports rather than `this`.
|
||||||
|
run: async (config) => module.exports.discover(config)
|
||||||
|
};
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// Custom agent to bypass self-signed certs typical in Proxmox
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
|
||||||
|
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
|
||||||
|
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
|
||||||
|
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
|
||||||
|
// the pairing honest -- the previous code collected MACs and IPs into two flat
|
||||||
|
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
|
||||||
|
class Interfaces {
|
||||||
|
constructor() { this.byMac = new Map(); this.anonymous = []; }
|
||||||
|
|
||||||
|
// Interfaces that belong to something running INSIDE the guest -- container
|
||||||
|
// engines, overlay networks, VPNs -- rather than to the guest itself. A
|
||||||
|
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
|
||||||
|
// alongside its one real NIC, which is noise in the directory and, worse,
|
||||||
|
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
|
||||||
|
// Only applied to guests; a hypervisor's own bridges are how you reach it.
|
||||||
|
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
|
||||||
|
|
||||||
|
static isVirtualName(name) {
|
||||||
|
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
|
||||||
|
// the only place the Proxmox node network API exposes a physical NIC's MAC
|
||||||
|
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
|
||||||
|
// rather than leaving every hypervisor MAC-less.
|
||||||
|
static macFromIfaceName(name) {
|
||||||
|
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
|
||||||
|
if (!m) return null;
|
||||||
|
return m[1].toLowerCase().match(/.{2}/g).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
static normalizeMac(mac) {
|
||||||
|
const m = (mac || '').toLowerCase().trim();
|
||||||
|
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
|
||||||
|
if (m === '00:00:00:00:00:00') return null;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ips` are the addresses observed on this one NIC (may be empty for a
|
||||||
|
// stopped guest, where only the MAC is known).
|
||||||
|
add(mac, ips, name) {
|
||||||
|
const key = Interfaces.normalizeMac(mac);
|
||||||
|
const addrs = (ips || []).filter(Boolean);
|
||||||
|
if (!key) {
|
||||||
|
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
|
||||||
|
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = this.byMac.get(key);
|
||||||
|
if (existing) {
|
||||||
|
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
|
||||||
|
existing.ip = existing.ips[0] || null;
|
||||||
|
if (!existing.name && name) existing.name = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
|
||||||
|
|
||||||
|
// The address/MAC the directory shows in its single-value columns, and what
|
||||||
|
// the reconciler matches on. Prefer a NIC that actually has an address.
|
||||||
|
primaryIp() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip);
|
||||||
|
return withIp ? withIp.ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryMac() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip && i.mac);
|
||||||
|
if (withIp) return withIp.mac;
|
||||||
|
const first = this.toArray().find(i => i.mac);
|
||||||
|
return first ? first.mac : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||||
|
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||||
|
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||||
|
// API token to confirm the URL + token are valid before scheduling runs.
|
||||||
|
validate: async (config) => {
|
||||||
|
const { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
|
||||||
|
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
let { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) {
|
||||||
|
throw new Error("Missing Proxmox config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure URL has no trailing slash
|
||||||
|
url = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
|
||||||
|
// several unrelated roots in the Directory tree and nothing says where any
|
||||||
|
// of them came from. Every node discovered below is parented to this, so
|
||||||
|
// one endpoint == one subtree.
|
||||||
|
const endpointHost = (() => {
|
||||||
|
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
|
||||||
|
})();
|
||||||
|
const clusterName = await (async () => {
|
||||||
|
// /cluster/status names the cluster when one exists; a standalone node
|
||||||
|
// has no cluster entry, in which case the endpoint hostname is the name.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
|
||||||
|
return entry ? entry.name : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: clusterName || `Proxmox (${endpointHost})`,
|
||||||
|
slug: endpointSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'proxmox',
|
||||||
|
address: url,
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
sourceId: url,
|
||||||
|
// Deliberately NO `ip`/`interfaces`: this resource stands for the
|
||||||
|
// cluster (the API endpoint), not for a machine. Giving it the address
|
||||||
|
// it is reached at made the reconciler match it to the very node that
|
||||||
|
// answers on that address -- the endpoint and the node collapsed into
|
||||||
|
// one row, which then became its own parent. The cluster is identified
|
||||||
|
// by slug + sourceId instead, which nothing else can collide with.
|
||||||
|
interfaces: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Get Nodes
|
||||||
|
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||||
|
if(!resNodes.ok) {
|
||||||
|
const errText = await resNodes.text();
|
||||||
|
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
|
||||||
|
}
|
||||||
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
// An offline node is still a real hypervisor that belongs in the
|
||||||
|
// directory -- skipping it entirely used to make it look decommissioned
|
||||||
|
// and let the reconciler's garbage collector archive it after a week of
|
||||||
|
// downtime. Record it, mark it down, and skip only the guest enumeration
|
||||||
|
// (which needs the node to answer).
|
||||||
|
const online = node.status === 'online';
|
||||||
|
|
||||||
|
const nodeSlug = `pve-node-${node.node}`;
|
||||||
|
|
||||||
|
// A hypervisor with no address is not actionable. Read its bridges/NICs
|
||||||
|
// so the node lands in the directory reachable and MAC-identified like
|
||||||
|
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
|
||||||
|
// normally the address you actually reach the hypervisor on.
|
||||||
|
const nodeIfaces = new Interfaces();
|
||||||
|
try {
|
||||||
|
const netRes = online
|
||||||
|
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
|
||||||
|
: { ok: false };
|
||||||
|
if (netRes.ok) {
|
||||||
|
const ifaceList = (await netRes.json()).data || [];
|
||||||
|
for (const iface of ifaceList) {
|
||||||
|
if (iface.iface === 'lo') continue;
|
||||||
|
const ip = iface.address || iface.cidr;
|
||||||
|
// This endpoint has no hwaddr field, so the MAC has to be recovered
|
||||||
|
// from a predictable interface name -- either this interface's own
|
||||||
|
// or, for a bridge, one of the physical ports beneath it.
|
||||||
|
let mac = Interfaces.macFromIfaceName(iface.iface);
|
||||||
|
if (!mac) {
|
||||||
|
for (const alt of (iface.altnames || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mac && iface.bridge_ports) {
|
||||||
|
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
|
||||||
|
mac = Interfaces.macFromIfaceName(port);
|
||||||
|
if (mac) break;
|
||||||
|
// The port may itself only carry the MAC in an altname.
|
||||||
|
const portDef = ifaceList.find(i => i.iface === port);
|
||||||
|
for (const alt of ((portDef && portDef.altnames) || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: node.node,
|
||||||
|
slug: nodeSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'hypervisor',
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
status: node.status,
|
||||||
|
sourceId: `${node.node}`,
|
||||||
|
node: node.node,
|
||||||
|
interfaces: nodeIfaces.toArray(),
|
||||||
|
macAddress: nodeIfaces.primaryMac(),
|
||||||
|
ip: nodeIfaces.primaryIp()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
|
||||||
|
|
||||||
|
// Everything below asks the node itself; an offline node answers none of
|
||||||
|
// it, and its guests are already recorded from previous runs.
|
||||||
|
if (!online) continue;
|
||||||
|
|
||||||
|
// 2. Get VMs for this node
|
||||||
|
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||||
|
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
|
||||||
|
|
||||||
|
for (const vm of vms) {
|
||||||
|
const vmSlug = `vm-${vm.vmid}`;
|
||||||
|
const isTemplate = vm.template === 1;
|
||||||
|
|
||||||
|
const ifaces = new Interfaces();
|
||||||
|
|
||||||
|
// Enrich from QEMU guest agent if running. The agent is the only source
|
||||||
|
// that knows which IP sits on which NIC, so pair them here rather than
|
||||||
|
// accumulating two flat lists (zipping those by index attributed IPs to
|
||||||
|
// the wrong MAC on any guest with more than one NIC).
|
||||||
|
if (vm.status === 'running') {
|
||||||
|
try {
|
||||||
|
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||||
|
if (agentRes.ok) {
|
||||||
|
const agentData = (await agentRes.json()).data;
|
||||||
|
if (agentData && agentData.result) {
|
||||||
|
for (const iface of agentData.result) {
|
||||||
|
// Docker bridges, veth pairs and VPN tunnels are the
|
||||||
|
// guest's own plumbing, not NICs of the guest.
|
||||||
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
|
const ips = (iface['ip-addresses'] || [])
|
||||||
|
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
|
||||||
|
.map(ip => ip['ip-address']);
|
||||||
|
ifaces.add(iface['hardware-address'], ips, iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich from VM config: the MAC is declared there whether or not the
|
||||||
|
// guest agent answered, so a stopped VM still gets a stable identity.
|
||||||
|
try {
|
||||||
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||||
|
if (configRes.ok) {
|
||||||
|
const confData = (await configRes.json()).data;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (confData[`net${i}`]) {
|
||||||
|
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
|
||||||
|
if(m) ifaces.add(m[1], [], `net${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: isTemplate ? 'template' : 'host',
|
||||||
|
name: vm.name || `VM ${vm.vmid}`,
|
||||||
|
slug: vmSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: isTemplate ? 'template' : 'vm',
|
||||||
|
vmid: vm.vmid,
|
||||||
|
// The Proxmox-side identity, so a resource can be traced back to the
|
||||||
|
// exact guest on the exact node it was discovered from.
|
||||||
|
sourceId: `${node.node}/qemu/${vm.vmid}`,
|
||||||
|
node: node.node,
|
||||||
|
isProduction: vm.status === 'running',
|
||||||
|
interfaces,
|
||||||
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get LXCs for this node
|
||||||
|
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
|
||||||
|
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
|
||||||
|
|
||||||
|
for (const lxc of lxcs) {
|
||||||
|
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||||
|
const isTemplate = lxc.template === 1;
|
||||||
|
|
||||||
|
const ifaces = new Interfaces();
|
||||||
|
|
||||||
|
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
|
||||||
|
// so read them off the same line instead of into parallel lists.
|
||||||
|
try {
|
||||||
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||||
|
if (configRes.ok) {
|
||||||
|
const confData = (await configRes.json()).data;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const line = confData[`net${i}`];
|
||||||
|
if (!line) continue;
|
||||||
|
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
|
||||||
|
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
|
||||||
|
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
const nameMatch = line.match(/\bname=([^,]+)/);
|
||||||
|
if (hwMatch || ipMatch) {
|
||||||
|
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// A DHCP-configured container has no IP in its config. Ask the running
|
||||||
|
// container's interface list so it lands in the directory addressable
|
||||||
|
// instead of as an IP-less row.
|
||||||
|
if (lxc.status === 'running' && !ifaces.primaryIp()) {
|
||||||
|
try {
|
||||||
|
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
|
||||||
|
if (ifRes.ok) {
|
||||||
|
for (const iface of ((await ifRes.json()).data || [])) {
|
||||||
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
|
const ip = (iface.inet || '').split('/')[0];
|
||||||
|
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: isTemplate ? 'template' : 'host',
|
||||||
|
name: lxc.name || `LXC ${lxc.vmid}`,
|
||||||
|
slug: lxcSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: isTemplate ? 'template' : 'lxc',
|
||||||
|
vmid: lxc.vmid,
|
||||||
|
sourceId: `${node.node}/lxc/${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
|
isProduction: lxc.status === 'running',
|
||||||
|
interfaces,
|
||||||
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
},
|
||||||
|
|
||||||
|
// The generalized plugin contract calls `run`; the discovery plugins keep
|
||||||
|
// `discover` as their implementation name for back-compat, and `run` is just
|
||||||
|
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||||
|
// detached and called as a bare function reference.
|
||||||
|
run: async (config) => module.exports.discover(config),
|
||||||
|
|
||||||
|
// Exported for unit tests only -- not part of the plugin contract.
|
||||||
|
_Interfaces: Interfaces
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `password` is
|
||||||
|
// secret and stored in OpenBao (secret/plugins/<instance-id>/conf).
|
||||||
|
type: 'unifi',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'UniFi Network',
|
||||||
|
description: 'Discover UniFi network devices and clients from a UniFi Controller / UDM endpoint.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
||||||
|
{ key: 'user', label: 'Username', type: 'text', required: true },
|
||||||
|
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
||||||
|
// succeeds only if one of the two login endpoints returns 200.
|
||||||
|
validate: async (config) => {
|
||||||
|
const { url, user, password } = config;
|
||||||
|
if (!url || !user || !password) return { ok: false, error: 'Missing url, user, or password' };
|
||||||
|
try {
|
||||||
|
let loginRes = await fetch(`${url}/api/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }), agent
|
||||||
|
});
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
loginRes = await fetch(`${url}/api/login`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }), agent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!loginRes.ok) return { ok: false, error: `UniFi auth failed (${loginRes.status})` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const { url, user, password } = config;
|
||||||
|
if (!url || !user || !password) {
|
||||||
|
throw new Error("Missing Unifi config");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authenticate
|
||||||
|
let loginRes = await fetch(`${url}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }),
|
||||||
|
agent
|
||||||
|
});
|
||||||
|
|
||||||
|
let isUdm = true;
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
loginRes = await fetch(`${url}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }),
|
||||||
|
agent
|
||||||
|
});
|
||||||
|
isUdm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
throw new Error(`Unifi auth failed: ${loginRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = loginRes.headers.get('set-cookie');
|
||||||
|
// UniFi often requires the CSRF token from the cookie
|
||||||
|
let csrf = '';
|
||||||
|
if (cookie) {
|
||||||
|
const match = cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
if (match) csrf = match[1];
|
||||||
|
}
|
||||||
|
const headers = { 'Cookie': cookie, 'X-Csrf-Token': csrf };
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
const basePath = isUdm ? '/proxy/network' : '';
|
||||||
|
|
||||||
|
// 2. Get Devices (Switches/APs)
|
||||||
|
const devRes = await fetch(`${url}${basePath}/api/s/default/stat/device`, { headers, agent });
|
||||||
|
const devData = (await devRes.json()).data || [];
|
||||||
|
|
||||||
|
for (const dev of devData) {
|
||||||
|
const devSlug = `unifi-device-${dev.mac.replace(/:/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'network_device',
|
||||||
|
name: dev.name || dev.model,
|
||||||
|
slug: devSlug,
|
||||||
|
metadata: {
|
||||||
|
make: 'Ubiquiti',
|
||||||
|
model: dev.model,
|
||||||
|
firmware: dev.version,
|
||||||
|
interfaces: [{ mac: dev.mac, ip: dev.ip }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get Clients
|
||||||
|
const clientRes = await fetch(`${url}${basePath}/api/s/default/stat/sta`, { headers, agent });
|
||||||
|
const clientData = (await clientRes.json()).data || [];
|
||||||
|
|
||||||
|
for (const client of clientData) {
|
||||||
|
const clientSlug = `unifi-client-${client.mac.replace(/:/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host', // Or unmanaged_device initially
|
||||||
|
name: client.hostname || client.name || client.mac,
|
||||||
|
slug: clientSlug,
|
||||||
|
metadata: {
|
||||||
|
interfaces: [{ mac: client.mac, ip: client.ip }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we know which switch/AP it's on
|
||||||
|
if (client.ap_mac) {
|
||||||
|
const apSlug = `unifi-device-${client.ap_mac.replace(/:/g, '')}`;
|
||||||
|
edges.push({ parentSlug: apSlug, childSlug: clientSlug, relation: 'connected_to' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||||
|
// this references module.exports rather than `this`.
|
||||||
|
run: async (config) => module.exports.discover(config)
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
type: 'twilio',
|
||||||
|
category: 'messaging',
|
||||||
|
name: 'Twilio SMS',
|
||||||
|
description: 'Send SMS messages (like 2FA codes) via Twilio.',
|
||||||
|
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
|
||||||
|
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
|
||||||
|
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
|
||||||
|
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async (config, payload) => {
|
||||||
|
const { to, message } = payload;
|
||||||
|
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
|
||||||
|
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
data.append('To', to);
|
||||||
|
data.append('From', config.fromNumber);
|
||||||
|
data.append('Body', message);
|
||||||
|
|
||||||
|
const postData = data.toString();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'api.twilio.com',
|
||||||
|
port: 443,
|
||||||
|
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Content-Length': Buffer.byteLength(postData)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(JSON.parse(body));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(postData);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
type: 'webhook',
|
||||||
|
category: 'messaging',
|
||||||
|
name: 'Universal REST Webhook',
|
||||||
|
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
|
||||||
|
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
|
||||||
|
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
|
||||||
|
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
|
||||||
|
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
|
||||||
|
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
if (!config.url) return { ok: false, error: 'URL is required' };
|
||||||
|
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
|
||||||
|
try {
|
||||||
|
if (config.headers) JSON.parse(config.headers);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: 'Headers must be valid JSON' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async (config, payload) => {
|
||||||
|
const { to, message } = payload;
|
||||||
|
let payloadStr = config.payloadTemplate || '{}';
|
||||||
|
|
||||||
|
// Replace template variables safely
|
||||||
|
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
|
||||||
|
|
||||||
|
// If there is an API secret, replace {{secret}} in the headers or url
|
||||||
|
let headersObj = {};
|
||||||
|
if (config.headers) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(config.headers);
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headersObj['Content-Type']) {
|
||||||
|
headersObj['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlObj = new URL(config.url);
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: config.method || 'POST',
|
||||||
|
headers: headersObj
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = urlObj.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = client.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve({ status: res.statusCode, body });
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(payloadStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,10 +3,22 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||||
|
.top-nav a.active{
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||||
|
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||||
|
sticky element offset itself below both fixed elements via
|
||||||
|
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||||
|
viewport's true top:0. */
|
||||||
|
--sw-content-offset: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spa-shell {
|
#spa-shell {
|
||||||
|
|||||||
@@ -67,13 +67,6 @@ app.user = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(args, callack){
|
|
||||||
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
|
|
||||||
app.api.delete('user/'+ args.uid, function(error, data){
|
|
||||||
callack(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePassword(args, callack){
|
function changePassword(args, callack){
|
||||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -102,7 +95,15 @@ app.user = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, remove, createInvite, setActive};
|
// A user DN's cn is always their uid (see models/user_ldap.js addLdapUser,
|
||||||
|
// `data.cn = data.uid`) -- pulling it straight out of the DN avoids an
|
||||||
|
// extra lookup just to display a manager list.
|
||||||
|
function dnToUid(dn){
|
||||||
|
var m = /^cn=([^,]+)/i.exec(dn || '');
|
||||||
|
return m ? m[1] : dn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, createInvite, setActive, dnToUid};
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -149,6 +150,21 @@ app.ui = (function(app){
|
|||||||
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
||||||
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
||||||
|
|
||||||
|
// All usernames, fetched once and shared across every user selector (e.g. manager pickers).
|
||||||
|
var _usersPromise = null;
|
||||||
|
function loadUsers(){
|
||||||
|
if(!_usersPromise){
|
||||||
|
_usersPromise = new Promise(function(resolve){
|
||||||
|
app.user.list(function(error, data){
|
||||||
|
if(error || !data || !data.results){ resolve([]); return; }
|
||||||
|
resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _usersPromise;
|
||||||
|
}
|
||||||
|
function refreshUsers(){ _usersPromise = null; return loadUsers(); }
|
||||||
|
|
||||||
// opts: { values, options, freeSolo, placeholder, name, separator }
|
// opts: { values, options, freeSolo, placeholder, name, separator }
|
||||||
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
||||||
function tagInput(mount, opts){
|
function tagInput(mount, opts){
|
||||||
@@ -249,7 +265,25 @@ app.ui = (function(app){
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
|
// Universal user selector (e.g. picking managers). Preloads all usernames.
|
||||||
|
function userSelect(mount, opts){
|
||||||
|
opts = opts || {};
|
||||||
|
var handle = tagInput(mount, {
|
||||||
|
name: opts.name || 'manager',
|
||||||
|
values: opts.values || [],
|
||||||
|
options: [],
|
||||||
|
freeSolo: opts.freeSolo !== false,
|
||||||
|
separator: opts.separator != null ? opts.separator : '\n',
|
||||||
|
placeholder: opts.placeholder || 'Type a username…',
|
||||||
|
});
|
||||||
|
loadUsers().then(function(users){ handle.setOptions(users); });
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups,
|
||||||
|
userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers,
|
||||||
|
};
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.oauthClient = (function(app){
|
app.oauthClient = (function(app){
|
||||||
@@ -265,13 +299,6 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(args, callack){
|
|
||||||
if(!confirm('Delete OAuth client "' + args.client_id + '"?')) return false;
|
|
||||||
app.api.delete('oauth/client/' + args.client_id, function(error, data){
|
|
||||||
callack(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(args, callack){
|
function update(args, callack){
|
||||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -284,7 +311,23 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { list, add, remove, update, rotateSecret };
|
return { list, add, update, rotateSecret };
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.tos = (function(app){
|
||||||
|
function get(callback){
|
||||||
|
return app.api.get('tos/', function(error, data){
|
||||||
|
if(callback) callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(args, callback){
|
||||||
|
app.api.put('tos/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, update };
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.apiToken = (function(app){
|
app.apiToken = (function(app){
|
||||||
@@ -339,7 +382,7 @@ app.impersonate = (function(app){
|
|||||||
|
|
||||||
app.token = (function(app){
|
app.token = (function(app){
|
||||||
function list(name, callack){
|
function list(name, callack){
|
||||||
if($.isFunction(name)){
|
if(typeof name === 'function'){
|
||||||
callack = name;
|
callack = name;
|
||||||
name = '';
|
name = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// Shared client framework for the theta42 apps.
|
||||||
|
//
|
||||||
|
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
||||||
|
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
||||||
|
// the /api/user/me response), never from edits to this file. Edit all three
|
||||||
|
// copies together.
|
||||||
|
//
|
||||||
|
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
||||||
|
|
||||||
var app = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if (!$.isFunction(callback)) {
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
return new Promise((resolve, reject) => {
|
// with the parsed body and rejects with the error body. get/options return
|
||||||
|
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
||||||
|
|
||||||
|
function body(method, url, data, callback){
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST', url: baseURL+url,
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
@@ -104,40 +121,27 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if (!$.isFunction(callback)) {
|
return body('PUT', url, data, callback);
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
$.ajax({
|
|
||||||
type: 'PUT', url: baseURL+url,
|
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: 'application/json; charset=utf-8',
|
|
||||||
dataType: 'json',
|
|
||||||
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return $.ajax({
|
|
||||||
type: 'PUT',
|
|
||||||
url: baseURL+url,
|
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
complete: function(res, text){
|
|
||||||
callback(
|
|
||||||
text !== 'success' ? res.statusText : null,
|
|
||||||
JSON.parse(res.responseText),
|
|
||||||
res.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if (!$.isFunction(callback)) {
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
return new Promise((resolve, reject) => {
|
// No request body is sent either way.
|
||||||
|
function remove(url, data, callback){
|
||||||
|
if(typeof data === 'function'){
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'DELETE', url: baseURL+url,
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
|||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(app){
|
app.auth = (function(app){
|
||||||
var user = {};
|
// One in-flight/cached GET /api/user/me per page load. Every gating
|
||||||
|
// decision (nav items, per-view forceLogin, group-required elements) reads
|
||||||
|
// this same promise instead of re-fetching.
|
||||||
|
var userPromise = null;
|
||||||
|
|
||||||
function setToken(token){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
|||||||
try{
|
try{
|
||||||
return await app.api.get('user/me');
|
return await app.api.get('user/me');
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(error?.status === 401) return null;
|
if(error && error.status === 401) return null;
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cached current user, or false when there's no token at all. Callers that
|
||||||
|
// need a fresh copy (after a login or a profile change) pass force.
|
||||||
|
function loadUser(force){
|
||||||
|
if(force || !userPromise){
|
||||||
|
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
||||||
|
userPromise = userPromise.then(function(user){
|
||||||
|
app.auth.user = app.auth.perms = user || null;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The apps report group membership two ways: sso-manager-node returns LDAP
|
||||||
|
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
||||||
|
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
||||||
|
// is exposed as a synthetic `admin` group so one gating model covers both.
|
||||||
|
function groupCNs(user){
|
||||||
|
var raw = (user && (user.memberOf || user.groups)) || [];
|
||||||
|
if(!Array.isArray(raw)) raw = [raw];
|
||||||
|
var names = raw.map(function(group){
|
||||||
|
return String(group).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
async function memberOf(groupNameToFind, user){
|
async function memberOf(groupNameToFind, user){
|
||||||
try{
|
user = user || await loadUser();
|
||||||
user = user || await app.auth.asyncUser;
|
if(!user) return false;
|
||||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
for(let group of user.memberOf){
|
return groupCNs(user).some(function(group){
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
return groupNameToFind.includes(group);
|
||||||
if(groupNameToFind.includes(group)) return true;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
throw(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLoggedIn(){
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
if(getToken()){
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
user = await app.auth.asyncUser;
|
function isAdmin(){
|
||||||
return user;
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
}else{
|
}
|
||||||
return false;
|
|
||||||
|
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
||||||
|
// an optional node-style callback with the same result.
|
||||||
|
function isLoggedIn(callback){
|
||||||
|
var promise = loadUser().then(function(user){
|
||||||
|
return user || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(typeof callback === 'function'){
|
||||||
|
promise.then(function(user){
|
||||||
|
callback(null, user);
|
||||||
|
}, function(error){
|
||||||
|
callback(error, false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logIn(args, callback){
|
function logIn(args, callback){
|
||||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
|||||||
if(data.login){
|
if(data.login){
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
}
|
}
|
||||||
|
loadUser(true);
|
||||||
callback(error, !!data.token);
|
callback(error, !!data.token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clears the session only — the caller decides where to go next (the nav's
|
||||||
|
// Log Out button uses ui.logoutRedirect).
|
||||||
function logOut(callback){
|
function logOut(callback){
|
||||||
localStorage.removeItem('APIToken');
|
localStorage.removeItem('APIToken');
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
userPromise = null;
|
||||||
callback();
|
app.auth.user = app.auth.perms = null;
|
||||||
|
if(typeof callback === 'function') callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
|
function consumeTokenFragment(){
|
||||||
|
if(!location.hash) return false;
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
var token = params.get('token');
|
||||||
|
if(!token) return false;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
|
// Drop the token from the address bar before navigating on.
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
window.location.href = redirect;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
|
// rather than by pausing document ready.
|
||||||
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
async function forceLogin(requiredGroups){
|
async function forceLogin(requiredGroups){
|
||||||
$.holdReady(true);
|
var user = await loadUser();
|
||||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
location.replace('/onboarding');
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(!await memberOf(requiredGroups)){
|
app.messages.action(
|
||||||
console.log("Does not have permission!!!")
|
`<h1>
|
||||||
app.util.actionMessage(
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
`<h1>
|
<b>You do not have permission to be here.</b>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
</h1>`,
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
$('#spa-shell'),
|
||||||
</h1>`,
|
'danger',
|
||||||
$('#spa-shell'),
|
);
|
||||||
'danger',
|
throw new Error("User does not have permission");
|
||||||
);
|
|
||||||
throw new Error("User does not have permission");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.holdReady(false);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||||
|
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||||
|
// is how the OIDC provider sends an unauthenticated user through login.
|
||||||
function logInRedirect(){
|
function logInRedirect(){
|
||||||
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
logInRedirect,
|
logInRedirect,
|
||||||
getUser,
|
|
||||||
memberOf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
app.auth.asyncUser = app.auth.getUser();
|
|
||||||
|
|
||||||
|
// Back-compat alias for views that awaited the cached user directly.
|
||||||
|
Object.defineProperty(app.auth, 'asyncUser', {
|
||||||
|
get: function(){ return app.auth.loadUser(); },
|
||||||
|
});
|
||||||
|
|
||||||
app.user = (function(app){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
@@ -338,6 +445,72 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
|
app.permission = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('permission/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjects(callback){
|
||||||
|
app.api.get('permission/subjects', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('permission/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, callback){
|
||||||
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.group = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('group/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('group/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(name, username, callback){
|
||||||
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(name, username, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, add, remove, addMember, removeMember};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
app.util = (function(app){
|
app.util = (function(app){
|
||||||
|
|
||||||
function getUrlParameter(name){
|
function getUrlParameter(name){
|
||||||
@@ -347,65 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
if($target.html() === message) return;
|
|
||||||
|
|
||||||
if($target.html()){
|
|
||||||
$target.slideUp('fast', function(){
|
|
||||||
$target.html('')
|
|
||||||
$target.removeClass (function(index, className){
|
|
||||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
|
||||||
});
|
|
||||||
if(message) return actionMessage(message, $target, type, callback);
|
|
||||||
$target.hide()
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
if(type) $target.addClass('bg-' + type);
|
|
||||||
|
|
||||||
if(!message.includes('<button')) message += `
|
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
$target.html(message).slideDown('fast');
|
|
||||||
}
|
|
||||||
setTimeout(callback,10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirm(message, $target, type, callback){
|
|
||||||
return new Promise((resolve, reject) =>{
|
|
||||||
let id = crypto.randomUUID();
|
|
||||||
message = `
|
|
||||||
<h4 class"align-middle" >
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
<b>${message}</b>
|
|
||||||
<span class="float-end">
|
|
||||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger confirm-${id}">
|
|
||||||
<i class="fa-solid fa-circle-stop"></i>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
`
|
|
||||||
actionMessage(message, $target, type);
|
|
||||||
$("body").on('click', `.confirm-${id}`, function(){
|
|
||||||
actionMessage('', $target, type);
|
|
||||||
resolve(!!$(this).data('confirm'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -413,10 +536,12 @@ app.util = (function(app){
|
|||||||
|
|
||||||
// Get the form values and work over them
|
// Get the form values and work over them
|
||||||
for (let {name, value} of $(this).serializeArray()) {
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
console.log(name, value)
|
|
||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
||||||
|
// can reset a list, e.g. the per-host IP/header controls).
|
||||||
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
||||||
){
|
){
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -458,30 +583,79 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll a just-added/-edited element into view and flash its
|
||||||
|
// background, so the user's eye lands on the row that changed instead of
|
||||||
|
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||||
|
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||||
|
function revealItem(el){
|
||||||
|
var node = el && el.jquery ? el[0] : el;
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node.scrollIntoView === 'function') {
|
||||||
|
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
}
|
||||||
|
var prevTransition = node.style.transition;
|
||||||
|
var prevBg = node.style.backgroundColor;
|
||||||
|
node.style.transition = 'background-color 1.5s ease';
|
||||||
|
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||||
|
setTimeout(function(){
|
||||||
|
node.style.backgroundColor = prevBg;
|
||||||
|
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
// The synthetic 'login' group is special: it's true for any authenticated user.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
var isLoggedIn = !!user;
|
||||||
|
|
||||||
// Show content if the user has the correct group
|
var style = document.getElementById('group-required-rules');
|
||||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
try{
|
try{
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
const sheet = document.styleSheets[0];
|
style.sheet.cssRules.length
|
||||||
const selector = `.group-required-${group}`;
|
);
|
||||||
const cssText = `${selector} { display: revert !important; }`;
|
|
||||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 'login' group is synthetic — it means "any authenticated user".
|
||||||
|
// Reveal .group-required-login for any logged-in user.
|
||||||
|
if(isLoggedIn){
|
||||||
|
try{
|
||||||
|
style.sheet.insertRule(
|
||||||
|
`.group-required-login { display: revert !important; }`,
|
||||||
|
style.sheet.cssRules.length
|
||||||
|
);
|
||||||
|
}catch(error){
|
||||||
|
// Ignore CSS escape errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( document ).ready(async function(){
|
||||||
|
|
||||||
|
// Show content the user's groups entitle them to.
|
||||||
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||||
|
|
||||||
$('div.row').fadeIn('slow'); //show the page
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
//panel button's
|
//panel button's
|
||||||
@@ -502,9 +676,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -521,7 +695,6 @@ $( document ).ready(async function(){
|
|||||||
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
||||||
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
||||||
|
|
||||||
console.log('y', y)
|
|
||||||
window.scrollTo({top: y, behavior: 'smooth'});
|
window.scrollTo({top: y, behavior: 'smooth'});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,31 +708,26 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
$form.validateClear();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
|
||||||
for(let keyError of data.keys){
|
for(let keyError of data.keys){
|
||||||
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
||||||
}
|
}
|
||||||
@@ -567,4 +735,3 @@ function formAJAX(btn){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
( function( $ ) {
|
|
||||||
var settings = {
|
|
||||||
rule: {
|
|
||||||
eq: function(value, options){
|
|
||||||
var compare = $('[name=' + options + ']').val();
|
|
||||||
|
|
||||||
if ( value != compare ) {
|
|
||||||
return "Miss-match";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validate = function(event) {
|
|
||||||
// let thisSettings = $.extend(true, settings, settingsObj);
|
|
||||||
let hasErrors = false;
|
|
||||||
|
|
||||||
if(this.is('[validate]')) return this.validateField(event);
|
|
||||||
|
|
||||||
if(!this.attr('isValid')){
|
|
||||||
console.log('adding reset event')
|
|
||||||
this.on('reset', function(){
|
|
||||||
$(this).attr('isValid', false);
|
|
||||||
$(this).validateClear();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.find('[validate]').each(function(){
|
|
||||||
if(!$(this).validateField()) hasErrors = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.attr('isValid', !hasErrors);
|
|
||||||
|
|
||||||
if(hasErrors && event) event.preventDefault();
|
|
||||||
|
|
||||||
return !hasErrors;
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validateClear = function(){
|
|
||||||
$(this).find('input').each(function(){
|
|
||||||
$(this).removeClass('is-invalid');
|
|
||||||
$(this).removeClass('is-valid');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateField = function(){
|
|
||||||
var attr = this.attr('validate').split(':'); //array of params
|
|
||||||
var rule = attr[0];
|
|
||||||
var options = attr[1];
|
|
||||||
var value = this.val(); //link to input value
|
|
||||||
var message;
|
|
||||||
|
|
||||||
if(this.prop('disabled')) return true;
|
|
||||||
|
|
||||||
|
|
||||||
//checks if field is required, and length
|
|
||||||
if(!isNaN(options) && value.length < options){
|
|
||||||
message = `Must be ${options} characters`;
|
|
||||||
}
|
|
||||||
|
|
||||||
//checks if empty to stop processing
|
|
||||||
if(!isNaN(options) && value.length === 0) {
|
|
||||||
}else if(rule in settings.rule){
|
|
||||||
let message = settings.rule[rule].apply(this, [value, options]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validateMessage(message)
|
|
||||||
return !message;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateMessage = function(message){
|
|
||||||
if(message && message !== true){
|
|
||||||
this.closest('.form-group').find('b.invalid-feedback').html(message);
|
|
||||||
this.addClass('is-invalid');
|
|
||||||
}else{
|
|
||||||
this.removeClass('is-invalid');
|
|
||||||
this.addClass('is-valid');
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
jQuery.extend({
|
|
||||||
validateSettings: function( settingsObj ) {
|
|
||||||
$.extend( true, settings, settingsObj );
|
|
||||||
},
|
|
||||||
|
|
||||||
validateInit: function( ettingsObj ) {
|
|
||||||
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
|
|
||||||
$( this ).validate( settingsObj, event );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}( jQuery ));
|
|
||||||
|
|
||||||
$.validateSettings({
|
|
||||||
rule:{
|
|
||||||
ip: function( value ) {
|
|
||||||
value = value.split( '.' );
|
|
||||||
|
|
||||||
if ( value.length != 4 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.each( value, function( key, value ) {
|
|
||||||
if( value > 255 || value < 0 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
host: function( value ) {
|
|
||||||
var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
user: function( value ) {
|
|
||||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
password: function( value ) {
|
|
||||||
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Weak password, Try again";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
# In a real environment, these would be derived from the script's download URL
|
||||||
|
# or passed as additional arguments. For now, we use the most recent release.
|
||||||
|
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
||||||
|
CONFIG_DIR="/etc/theta42"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/agent.yml"
|
||||||
|
BIN_PATH="/usr/local/bin/theta-agent"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/theta-agent.service"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# 1. Root check
|
||||||
|
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||||
|
error "This script must be run as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install SSSD and PAM integration packages if missing
|
||||||
|
install_sssd_deps() {
|
||||||
|
if ! command -v sssd >/dev/null 2>&1; then
|
||||||
|
log "Installing SSSD and PAM integration dependencies..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
|
||||||
|
if command -v pam-auth-update >/dev/null 2>&1; then
|
||||||
|
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
|
||||||
|
fi
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y sssd sssd-ldap sssd-tools || true
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y sssd sssd-ldap sssd-tools || true
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
pacman -S --noconfirm sssd || true
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
zypper in -y sssd || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "SSSD is already installed."
|
||||||
|
fi
|
||||||
|
mkdir -p /etc/sssd
|
||||||
|
chmod 755 /etc/sssd
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Argument Parsing
|
||||||
|
URL=""
|
||||||
|
TOKEN=""
|
||||||
|
JOIN_KEY=""
|
||||||
|
PUBLIC_KEY=""
|
||||||
|
B64_CONFIG=""
|
||||||
|
INSTALL_SSSD=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--url)
|
||||||
|
URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
TOKEN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
|
||||||
|
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
|
||||||
|
# it and REFUSES them when it is absent, so an install without this key can
|
||||||
|
# stream telemetry but cannot be acted on.
|
||||||
|
--public-key)
|
||||||
|
PUBLIC_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
# The one credential an operator hands out. The server exchanges it for a
|
||||||
|
# per-agent token on first connect, which the agent writes back into
|
||||||
|
# agent.yml -- so this is all you need to add a host.
|
||||||
|
--join-key)
|
||||||
|
JOIN_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--install-sssd|--ldap)
|
||||||
|
INSTALL_SSSD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
B64_CONFIG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validation: require credentials ONLY if config file does not already exist
|
||||||
|
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||||
|
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||||
|
echo "Usage examples:"
|
||||||
|
echo " sh install.sh \"BASE64_CONFIG\""
|
||||||
|
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
|
||||||
|
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
|
||||||
|
echo ""
|
||||||
|
echo "--join-key is the normal path: the host enrolls itself on first connect"
|
||||||
|
echo "and the SSO issues it its own token + public key, which the agent writes"
|
||||||
|
echo "back into agent.yml. Get a key from Directory -> Install Agent."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting Theta Agent installation..."
|
||||||
|
|
||||||
|
# Architecture and OS detection
|
||||||
|
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ARCH_NAME="$(uname -m)"
|
||||||
|
BINARY_NAME="theta-agent-linux-amd64"
|
||||||
|
|
||||||
|
case "$OS_NAME" in
|
||||||
|
linux*)
|
||||||
|
case "$ARCH_NAME" in
|
||||||
|
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||||
|
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
|
||||||
|
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
|
||||||
|
*) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
darwin*)
|
||||||
|
case "$ARCH_NAME" in
|
||||||
|
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
|
||||||
|
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||||
|
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
mingw*|msys*|cygwin*)
|
||||||
|
case "$ARCH_NAME" in
|
||||||
|
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
|
||||||
|
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
|
||||||
|
|
||||||
|
# 3. Install binary
|
||||||
|
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
|
||||||
|
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
|
||||||
|
chmod +x "$BIN_PATH.tmp"
|
||||||
|
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||||
|
|
||||||
|
# 4. Setup configuration
|
||||||
|
log "Preparing configuration directory $CONFIG_DIR..."
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
chmod 755 "$CONFIG_DIR"
|
||||||
|
|
||||||
|
if [ -n "$B64_CONFIG" ]; then
|
||||||
|
log "Decoding and writing configuration from base64..."
|
||||||
|
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
||||||
|
elif [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
log "Generating minimal configuration from arguments..."
|
||||||
|
cat <<EOF > "$CONFIG_FILE"
|
||||||
|
server_url: "$URL"
|
||||||
|
auth_token: "$TOKEN"
|
||||||
|
join_key: "$JOIN_KEY"
|
||||||
|
public_key: "$PUBLIC_KEY"
|
||||||
|
location: "unknown"
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
ldap_tunnel: true
|
||||||
|
reboot: false
|
||||||
|
service_control: []
|
||||||
|
arbitrary_bash: false
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
log "Preserving existing configuration at $CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
# Ensure theta-secrets & theta groups exist for non-root secret access
|
||||||
|
log "Configuring non-root secret access groups (theta-secrets)..."
|
||||||
|
if command -v groupadd >/dev/null 2>&1; then
|
||||||
|
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
|
||||||
|
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
SECRETS_GROUP="root"
|
||||||
|
if getent group theta-secrets >/dev/null 2>&1; then
|
||||||
|
SECRETS_GROUP="theta-secrets"
|
||||||
|
elif getent group theta >/dev/null 2>&1; then
|
||||||
|
SECRETS_GROUP="theta"
|
||||||
|
fi
|
||||||
|
chown -R "root:$SECRETS_GROUP" "$CONFIG_DIR" 2>/dev/null || true
|
||||||
|
chmod 750 "$CONFIG_DIR"
|
||||||
|
chmod 640 "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# 4c. Setup Desktop Tray Icon companion
|
||||||
|
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
|
||||||
|
case "$OS_NAME" in
|
||||||
|
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
|
||||||
|
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
|
||||||
|
esac
|
||||||
|
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
|
||||||
|
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
|
||||||
|
|
||||||
|
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
|
||||||
|
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
|
||||||
|
chmod +x "$TRAY_BIN_PATH.tmp"
|
||||||
|
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
|
||||||
|
mkdir -p /etc/xdg/autostart
|
||||||
|
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Theta Agent Tray
|
||||||
|
Comment=Theta Agent Desktop Tray Companion
|
||||||
|
Exec=/usr/local/bin/theta-agent-tray
|
||||||
|
Icon=network-workgroup
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;System;
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
EOF
|
||||||
|
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Setup systemd service
|
||||||
|
log "Creating systemd service unit..."
|
||||||
|
cat <<EOF > "$SERVICE_FILE"
|
||||||
|
[Unit]
|
||||||
|
Description=Theta Agent Unified Endpoint Management
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$BIN_PATH
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
SyslogIdentifier=theta-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 6. Start the agent
|
||||||
|
log "Enabling and starting Theta Agent..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable theta-agent
|
||||||
|
systemctl start theta-agent
|
||||||
|
|
||||||
|
log "Theta Agent installation complete!"
|
||||||
|
log "Verify status with: systemctl status theta-agent"
|
||||||
|
log "Check logs with: journalctl -u theta-agent -f"
|
||||||