Compare commits

...

5 Commits

Author SHA1 Message Date
wmantly 59d68c0269 chore: release v1.19.6 - UI nav auth, SMTP UI-only, test messages, directory.md
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m6s
Pull Request Tests / Run Tests (20.x) (push) Failing after 29s
Pull Request Tests / Run Tests (22.x) (push) Failing after 29s
Pull Request Tests / Test Summary (push) Failing after 4s
### Fixed
- **Navbar shows Catalog/Vault for unauthenticated users** — Changed nav
  gating from `groups: []` (always visible) to `groups: ['login']` and
  added synthetic 'login' group handling in app-base.js.
- **500 ENOENT: no such file or directory, open '/docs/directory.md'** —
  Created the missing documentation file.

### Changed
- **SMTP configuration UI-only** — Removed SMTP from static config files
  (conf/base.js, sso-secrets.js, setup.env.example). SMTP is now only
  configurable via the runtime UI at /conf.

### Added
- **Test email/SMS capability** — Added POST /api/conf/test-email and
  POST /api/conf/test-sms endpoints with UI buttons in the Configuration
  page. Saves config first, then sends test message to verify settings.

### theta-env setup.sh
- **Non-interactive theta-agent configuration** — Added CFG_THETA_AGENT_ENABLE,
  CFG_THETA_AGENT_LDAP_AUTH, and CFG_THETA_AGENT_FULL_CONTROL variables to
  setup.env (all default to 1/enabled).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 21:30:47 -04:00
wmantly ef2207ed72 fix(vault): correctly rewrite paths for vault API proxy (#149) 2026-08-02 19:47:07 -04:00
wmantly 7782cf8973 Merge pull request #148 from theta42/bump-1.19.5
chore: bump version to 1.19.5
2026-08-02 19:07:19 -04:00
wmantly 80317d1b7e chore: bump version to 1.19.5
Pull Request Tests / Run Tests (18.x) (push) Failing after 6m7s
Pull Request Tests / Run Tests (20.x) (push) Failing after 24s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 19:02:17 -04:00
wmantly ded6a1b0d5 Merge pull request #147 from theta42/fix-68
fix: enforce pwdAccountLockedTime check in app and LDAP
2026-08-02 19:01:43 -04:00
11 changed files with 349 additions and 32 deletions
+4
View File
@@ -644,3 +644,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
## [1.19.6] - 2026-08-02
### Fixed
- Fixed Vault API returning 403 on the Secrets List due to `http-proxy-middleware` v2 rewriting the path incorrectly (it previously appended the `/api/vault/` mount path to the proxied Vault request).
+139
View File
@@ -0,0 +1,139 @@
---
layout: default
title: Directory Management
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
---
# Directory Management
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
## Architecture
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
There are three primary **Kinds** of resources you can define:
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
## Automatic LDAP Group Creation
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
1. `<slug>_access` (Member level access)
2. `<slug>_admin` (Owner level access)
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
## Resource Metadata
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
### Common Metadata
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
- **IP Address**: The internal IP address of the resource.
- **MAC Address**: The hardware address of the primary interface.
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
- **Production Environment**: A boolean toggle indicating if the resource is in production.
### Host Metadata
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
### Service Metadata
- **Internal Port**: The local port the service binds to (e.g. `8080`).
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
### Who sees which metadata
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
## Catalog & access requests
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
- **Discover More** — everything else in the directory, with a **Request access** button.
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## Consumers of the directory
The inventory graph isn't just documentation — other components read it to make decisions:
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
- `POST /api/access-requests``{slug | resourceId, groupCn?, note?}`
- `GET /api/access-requests/mine` — the caller's own history
- `GET /api/access-requests` — pending requests the caller may decide
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
-8
View File
@@ -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 <noreply@example.com>',
},
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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.19.4",
"version": "1.19.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.19.4",
"version": "1.19.6",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.19.4",
"version": "1.19.6",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+15 -1
View File
@@ -615,9 +615,10 @@ app.util = (function(app){
// 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);
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(){
+63
View File
@@ -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 || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
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;
-12
View File
@@ -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);
+4 -5
View File
@@ -38,16 +38,15 @@ module.exports = {
// app-base.js, which reveals .group-required-<cn> 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/<uid>/*.
{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']},
],
};
+1 -1
View File
@@ -202,7 +202,7 @@ function vaultProxy() {
return createProxyMiddleware({
target: VAULT_ADDR,
changeOrigin: true,
pathRewrite: { '^/': '/v1/' },
pathRewrite: { '^/api/vault': '/v1' },
on: {
proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options);
+120 -2
View File
@@ -45,7 +45,7 @@
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
const payload = {
smtp: {
host: $('#smtp-host').val(),
@@ -79,7 +79,82 @@
btn.prop('disabled', false).html('<i class="fas fa-save"></i> 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('<i class="fas fa-spinner fa-spin"></i> 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('<i class="fas fa-spinner fa-spin"></i> 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 @@
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
@@ -248,6 +345,17 @@
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test Email</label>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
</div>
</div>
</div>
</div>
@@ -306,6 +414,16 @@
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
</div>