diff --git a/docs/directory.md b/docs/directory.md new file mode 100644 index 0000000..1287c6c --- /dev/null +++ b/docs/directory.md @@ -0,0 +1,139 @@ +--- +layout: default +title: Directory Management +description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API. +--- + +# Directory Management + +The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services. + +## Architecture + +The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`). + +There are three primary **Kinds** of resources you can define: +- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents. +- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host. +- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service. +- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service. + +By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure. + +## Automatic LDAP Group Creation + +When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource: + +1. `_access` (Member level access) +2. `_admin` (Owner level access) + +For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard. + +## Resource Metadata + +Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields: + +### Common Metadata +- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`). +- **IP Address**: The internal IP address of the resource. +- **MAC Address**: The hardware address of the primary interface. +- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`). +- **Production Environment**: A boolean toggle indicating if the resource is in production. + +### Host Metadata +- **VMID**: The hypervisor VM or Container ID (e.g. `101`). +- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`). +- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`). + +### Service Metadata +- **Internal Port**: The local port the service binds to (e.g. `8080`). +- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank). +- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication. +- **External Reachable**: Indicates if the service is accessible outside the VPN/local network. +- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`). +- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`). +- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`). + +### Who sees which metadata + +Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers: + +- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`. +- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`). +- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included. + +Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too. + +## Catalog & access requests + +The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows: + +- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh _-_@`; otherwise they fall back to a direct `ssh @`. +- **Discover More** — everything else in the directory, with a **Request access** button. +- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource. + +A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring. + +Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service. + +## Navigating the UI + +The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it. + +Directory & inventory list view + +## Slug conventions + +Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention: + +- **Sites**: `site_` — e.g. `site_local`, `site_us-east` +- **Hosts**: `host_` — e.g. `host_pve1`, `host_web01` +- **Services/apps**: a plain slug or `app_` — e.g. `sso-manager`, `app_emby` + +The auto-created LDAP groups derive from the slug (`_access` / `_admin`), so keep slugs stable once access groups are in use. + +## Automatic registration + +You don't have to build the graph by hand — the theta42 tooling registers itself: + +### The stack itself (theta-env) + +[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys: + +- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site +- the **host** the stack runs on (`host_`), with IP, MAC address, OS, and kernel collected from the machine +- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo +- the proxy's auto-registered **OAuth client**, linked under its service + +The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values. + +### Linux hosts (ldap-client) + +The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location. + +## Consumers of the directory + +The inventory graph isn't just documentation — other components read it to make decisions: + +- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=`), then bridges them in. The `host_` slugs and `host__access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group. + +Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9. + +## API + +All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`): + +- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id` +- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations) +- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links +- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column) +- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group +- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me` + +Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin): + +- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}` +- `GET /api/access-requests/mine` — the caller's own history +- `GET /api/access-requests` — pending requests the caller may decide +- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny` +- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index ed989a1..647c515 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -57,14 +57,6 @@ module.exports = { password: '__in secrets file__', did: '__in secrets file__', }, - smtp: { - host: 'localhost', - port: 587, - secure: false, - user: 'noreply@example.com', - pass: '__in secrets file__', - from: 'SSO Manager ', - }, directory: { // Public SSH jump host fronting the lab, if there is one (the jump-host // component). When set, a host card in the catalog shows the real diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 88f4290..85f91d5 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -615,9 +615,10 @@ app.util = (function(app){ // Reveal every .group-required- 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); - if(!groups.length) return; + var isLoggedIn = !!user; var style = document.getElementById('group-required-rules'); if(!style){ @@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){ // 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(){ diff --git a/nodejs/routes/api_conf.js b/nodejs/routes/api_conf.js index 9a2d33d..03deea7 100644 --- a/nodejs/routes/api_conf.js +++ b/nodejs/routes/api_conf.js @@ -128,4 +128,67 @@ router.post('/proxy', async (req, res, next) => { } }); +// Send a test email to verify SMTP configuration +router.post('/test-email', async (req, res, next) => { + try { + const { to, subject, body } = req.body || {}; + if (!to) { + return res.status(400).json({ error: 'Recipient email address is required' }); + } + + // Use the email model to send the test message + const Email = require('../models/email'); + const testSubject = subject || 'SSO Manager Test Email'; + const testBody = body || `

This is a test email from SSO Manager.

If you received this, your SMTP configuration is working correctly.

Sent at: ${new Date().toISOString()}

`; + + await Email.send(to, testSubject, testBody); + res.json({ success: true, message: `Test email sent to ${to}` }); + } catch(err) { + next(err); + } +}); + +// Send a test SMS to verify VoIP.ms configuration +router.post('/test-sms', async (req, res, next) => { + try { + const { to, message } = req.body || {}; + if (!to) { + return res.status(400).json({ error: 'Recipient phone number is required' }); + } + + const voipmsConf = conf.voipms || {}; + if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) { + return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' }); + } + + const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`; + + // VoIP.ms SMS API endpoint + const voipmsApiUrl = 'https://api.voip.ms/v1.0'; + const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64'); + + const response = await fetch(`${voipmsApiUrl}/sms/send`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${authHeader}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + did: voipmsConf.did, + to: to, + message: testMessage + }) + }); + + const result = await response.json(); + if (result.status === 'success') { + res.json({ success: true, message: `Test SMS sent to ${to}` }); + } else { + res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` }); + } + } catch(err) { + next(err); + } +}); + module.exports = router; \ No newline at end of file diff --git a/nodejs/test-proxy.js b/nodejs/test-proxy.js deleted file mode 100644 index 240bad5..0000000 --- a/nodejs/test-proxy.js +++ /dev/null @@ -1,12 +0,0 @@ -const express = require('express'); -const { createProxyMiddleware } = require('http-proxy-middleware'); -const app = express(); -app.use('/', createProxyMiddleware({ - target: 'http://localhost:8080', - on: { - proxyRes: (proxyRes, req, res) => { - delete proxyRes.headers['x-frame-options']; - } - } -})); -app.listen(3004); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index fe3fd1e..6f27c75 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -38,16 +38,15 @@ module.exports = { // app-base.js, which reveals .group-required- for each group the user is // in (plus the synthetic `admin` group when user/me reports isAdmin). nav: [ - // Ungated on purpose: the catalog is the one page that exists for - // ordinary users. Before this, every nav item was admin-only and a - // non-admin had no signposted destination at all. - {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []}, + // Catalog requires login - it's the end-user view of their accessible resources. + {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']}, {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, {href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, - {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []}, + // Vault requires login - per-user secrets at secret/users//*. + {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, ], }; diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index b2dae87..a95939b 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -45,7 +45,7 @@ async function saveConf() { const btn = $('#btn-save'); btn.prop('disabled', true).html(' Saving...'); - + const payload = { smtp: { host: $('#smtp-host').val(), @@ -79,7 +79,82 @@ btn.prop('disabled', false).html(' Save Configuration'); } } - + + async function sendTestEmail() { + const to = $('#test-email-to').val().trim(); + if (!to) { + app.messages.toast('Please enter a recipient email address', 'warning'); + return; + } + + const $inputGroup = $('#test-email-to').closest('.input-group'); + const btn = $inputGroup.find('button'); + const originalHtml = btn.html(); + btn.prop('disabled', true).html(' Sending...'); + + try { + // First save the SMTP config, then send test email + const payload = { + smtp: { + host: $('#smtp-host').val(), + port: parseInt($('#smtp-port').val(), 10) || 587, + user: $('#smtp-user').val(), + pass: $('#smtp-pass').val(), + from: $('#smtp-from').val(), + secure: $('#smtp-secure').is(':checked') + } + }; + + // Save config first + await app.api.post('conf', payload); + + // Then send test email + const result = await app.api.post('conf/test-email', { to }); + app.messages.toast(result.message || 'Test email sent!', 'success'); + $('#test-email-to').val(''); + } catch (error) { + app.messages.toast('Failed to send test email: ' + (error.message || 'Unknown error'), 'danger'); + } finally { + btn.prop('disabled', false).html(originalHtml); + } + } + + async function sendTestSms() { + const to = $('#test-sms-to').val().trim(); + if (!to) { + app.messages.toast('Please enter a recipient phone number', 'warning'); + return; + } + + const $inputGroup = $('#test-sms-to').closest('.input-group'); + const btn = $inputGroup.find('button'); + const originalHtml = btn.html(); + btn.prop('disabled', true).html(' Sending...'); + + try { + // First save the VoIP.ms config, then send test SMS + const payload = { + voipms: { + username: $('#voipms-username').val(), + did: $('#voipms-did').val(), + password: $('#voipms-password').val() + } + }; + + // Save config first + await app.api.post('conf', payload); + + // Then send test SMS + const result = await app.api.post('conf/test-sms', { to }); + app.messages.toast(result.message || 'Test SMS sent!', 'success'); + $('#test-sms-to').val(''); + } catch (error) { + app.messages.toast('Failed to send test SMS: ' + (error.message || 'Unknown error'), 'danger'); + } finally { + btn.prop('disabled', false).html(originalHtml); + } + } + function togglePassword(id) { const el = document.getElementById(id); if (el.type === 'password') { @@ -239,6 +314,28 @@
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
+
+
+ +
+ + +
+
Send a test SMS to verify your VoIP.ms configuration is working.
+
+ +
+
+ +
+ + +
+
Send a test SMS to verify your VoIP.ms configuration is working.
@@ -248,6 +345,17 @@
+
+
+ +
+ + +
+
Send a test email to verify your SMTP configuration is working.
+
@@ -306,6 +414,16 @@
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
+
+
+ +
+ + +
+
Send a test SMS to verify your VoIP.ms configuration is working.