Commit Graph

44 Commits

Author SHA1 Message Date
wmantly d486fb946b feat(multi-site): auto-assign LDAP ServerID + replication hosts at join time
OpenLDAP N-way multi-master replication (docs/replication.md) required
an operator to hand-set LDAP_SERVER_ID (unique per site) and
LDAP_REPLICATION_HOSTS (every OTHER site's LDAP URL, kept in sync by
hand across every node) -- real coordination work, and easy to get
wrong or let drift as sites are added.

Automates the coordination the master is already in a position to do:
- SiteSpoke gets ldapServerId, auto-assigned (next free from 2 upward,
  1 reserved for the master) at registration and reused across
  re-registrations -- same pattern as jump-host's mesh index.
- ldapHost is derived from each site's already-known HTTP(S) endpoint
  (same hostname, port 636) rather than a separately-configured field
  that could drift from it.
- New utils/ldap_replication.js (nextFreeLdapServerId, ldapHostFor),
  shared between the spoke-facing GET /api/site/ldap-peers (Bearer
  site join key, returns this caller's own ID + every peer) and the
  master-local GET /directory-admin/ldap-replication-config (computes
  its own config directly from SiteSpoke, no HTTP round-trip needed).

Verified against real running containers (docker-compose.multisite-e2e.yml):
after a real join, the master's computed config correctly includes the
spoke as a peer with an assigned ID, and the spoke's own fetched
config matches that ID and correctly excludes itself from its own
peer list.

Known limitation, documented in docs/replication.md: the master's own
LDAP_REPLICATION_HOSTS only gets recomputed when ITS setup.sh is
re-run (or an admin re-applies it directly) -- there's no live push to
an already-running master when a new spoke joins. A spoke's own config
is re-checked on every setup.sh run, which is the common/recurring
event; the master side is a documented manual step for now rather than
a live hot-reload (which would need OpenLDAP's dynamic cn=config
backend -- a bigger change, deliberately out of scope here to avoid
risking a live directory's LDAP replication on undertested config).
2026-08-10 22:51:04 -04:00
wmantly e9310a1e5c Merge remote-tracking branch 'origin/master' into feat-real-gateway-count-and-service-wiring
# Conflicts:
#	nodejs/package.json
2026-08-10 22:27:22 -04:00
wmantly 358231532f Merge remote-tracking branch 'origin/master' into feat-real-gateway-count-and-service-wiring
# Conflicts:
#	nodejs/package.json
2026-08-10 22:24:55 -04:00
wmantly d8bd338814 Merge remote-tracking branch 'origin/master' into fix-nmap-rttvar-false-failure
# Conflicts:
#	nodejs/package.json
2026-08-10 22:24:20 -04:00
wmantly 611f1a3318 feat(directory): real mesh-gateway count on the Multi-Site modal; docs links
"Theta Gateways: N active gateways" was counting this app's own
unrelated WireGuard roaming-client/exit-node Resources
(metadata.subType === 'wireguard') -- a completely different subsystem
from the gateway-to-gateway mesh the modal is actually about, and it
never queried jump-host's mesh registry at all (so it couldn't show
the local self-entry either, since there was nothing mesh-related
being counted in the first place).

Added utils/jump_client.js (same pattern as utils/proxy_client.js:
reuses jump-host's existing self-service jmp_ API token system rather
than inventing a new credential) to query jump-host's real
GET /api/mesh/gateways. Reports a null count (not misleading 0) when
the integration isn't configured/reachable, surfaced distinctly in the
UI. Also added help links to the published multi-site/mesh docs on the
modal.

Includes docs links + count only -- this session also discovered that
utils/proxy_client.js's PROXY_INTERNAL_URL, and now JUMP_INTERNAL_URL,
were never actually wired into theta-suite's docker-compose.yml, so
both service-to-service integrations were unreachable in every real
deployment despite existing in code (fixed in theta-suite separately).
2026-08-10 22:17:57 -04:00
wmantly e313697bfd fix(discovery): recover an nmap scan that succeeded despite a benign stderr warning
node-nmap (the vendored library, not our code) treats ANY stderr
output from the nmap binary as a fatal scan error -- including nmap's
own harmless RTT-calibration warning ("RTTVAR has grown to over N
seconds, decreasing to M"), which it prints *during* a scan that goes
on to complete normally. That meant a real, successful scan (valid XML
already sitting in the library's rawData) got discarded and reported
as a failed run with zero hosts discovered -- not just log noise as
initially assumed.

Recover in our own plugin code by detecting this specific known-benign
message and manually re-running node-nmap's own XML-parse-then-complete
path when there's actually output to parse. A genuine parse failure or
any other error message still rejects exactly as before -- this only
widens the recovery path.
2026-08-10 22:14:13 -04:00
wmantly 2c3ec4e967 fix(directory): dedupe access/admin groups on repeated promotion; stop self-healing on every GET
Three independent copies of the same bug: routes/discovery.js's
POST /discovery/promote/:slug (the actual "Promote" button in the UI)
and services/discovery_reconciler.js's autoPromote path both called
ResourceGroup.create() directly with no existence check -- unlike
routes/api_directory_admin.js's own ensureResourceGroup, which already
carried a comment describing this exact "groups appear 3x" bug and
fixing it, just not everywhere it occurred. ResourceGroup has no DB
unique constraint on (resourceId, groupCn), so a resource promoted
more than once (retried UI click, or the same LXC discovered from
multiple Proxmox cluster nodes) silently accumulated duplicate
access/admin rows every time. Added ResourceGroup.ensure() (the
existing check-then-create pattern, now on the model) and switched all
three call sites to it. New regression test in tests/reconciler.test.js.

Also: GET /api/directory-admin/resources ran a full group-model
self-heal fan-out (ensureSiteGroups per site + provisionResourceGroups
per resource, each several sequential LDAP round-trips) unconditionally
on every single list -- confirmed via code read as the actual
bottleneck once a directory has more than a handful of resources, not
data volume. Moved healing to where resources actually change instead
(POST/PUT /resources, POST /discovery/promote/:slug -- PUT had none at
all before this), and added POST /resources/heal-groups as an explicit
on-demand equivalent for backfilling a directory seeded before this
change.
2026-08-10 22:08:04 -04:00
wmantly a0964ce350 feat(multi-site): route replication traffic over the mesh when available
Cross-component routing TODO item: a spoke's resync push now prefers
its WG mesh IP (reported via the noInbound/meshIp fields added for the
relay automation) over the public endpoint, falling back to the public
endpoint if the mesh attempt fails for any reason (tunnel not actually
up between these two particular gateways yet, transient failure,
etc.) -- a mesh-routing preference must never turn into "spoke stops
getting updates."

Plain HTTP over the mesh IP, not HTTPS: the WG tunnel is already
encrypted, same reasoning already applied to the no-inbound relay
terminating at the master.

A spoke with no meshIp on file behaves exactly as before (public
endpoint only) -- this is additive, not a behavior change for spokes
that haven't opted into mesh registration.
2026-08-10 20:39:27 -04:00
wmantly b95cb08c41 feat(multi-site): no-inbound relay automation via theta-proxy's existing API tokens
Implements TODO items "service-to-service auth model" and "no-inbound
relay automation" together -- the first was never really "no credential
type exists", it was that nothing wired one of the credential types that
ALREADY exist (theta-proxy, jump-host, and this app each already have
their own self-service API token system, models/api_token.js) into an
actual inter-service call. This is that wiring, not a new invented
credential type.

- utils/proxy_client.js: ensureRelayRoute({host, ip, targetPort}) calls
  theta-proxy's real Host API (GET/POST/PUT /api/host) using a `prx_...`
  token an operator mints on theta-proxy and stores in OpenBao
  (secret/integrations/theta-proxy), same pattern as agent_keys.js.
  Idempotent and best-effort -- never fails the caller if the token/URL
  isn't configured, since this is an enhancement on top of a working
  join, not a join requirement.
- POST /api/site/spokes accepts optional noInbound/meshIp/publicHost
  fields; when a spoke reports itself no-inbound, the master
  best-effort creates/updates the matching relay route automatically.
  SiteSpoke gained the fields + a relayNote for visibility.

Verified against a REAL running theta-proxy container (not mocked):
booted it standalone, logged in as the local admin, minted a real
`prx_` token via its actual API, and drove ensureRelayRoute() against
it for real. Caught a real bug doing this: GET /api/host/:item wraps
the record in `{item, results}`, not flat -- the mocked unit tests
(which I wrote first) all had the flat shape baked in and passed
cleanly, so this only surfaced against the real API. Fixed in both the
implementation and the unit tests' mocked response shape.
2026-08-10 20:31:42 -04:00
wmantly d27763e556 feat(multi-site): live catalog replication + identical-directory signing key
The shipped join flow (v2.2.0-v2.3.0) was a one-time snapshot: a spoke's
catalog never updated after joining. This adds the two pieces that were
explicitly designed but missing:

- Live replication: a spoke registers its own endpoint with the master
  right after joining (POST /api/site/spokes, Bearer join-key), receiving
  a pushToken. Every successful catalog write on the master now fires a
  fire-and-forget resync ping (utils/site_replicate.js) at every known
  spoke, concurrently -- one unreachable spoke never blocks or delays
  another (wired into the existing write-gate middleware in
  api_directory_admin.js). The spoke's POST /api/site/resync handler
  reuses the already-tested export+import path rather than applying a
  partial diff.

- Identical directories: POST /api/site/export now best-effort includes
  the master's agent-signing key; a spoke adopts it via agent_keys.adopt()
  on both join and every resync, so every site's sso-manager can validly
  sign a command for any agent enrolled anywhere -- the accepted tradeoff
  discussed for this deployment's scale (blast radius for simplicity).

New SiteSpoke model tracks registered spokes (endpoint + pushToken);
registered it in models/index.js (a real bug the e2e test below caught --
SiteSpoke.list() 500'd with "Cannot read properties of null (reading
'adapter')" until the model was added to initORM's model list).

Verified end-to-end against docker-compose.multisite-e2e.yml: mint join
key -> join with selfUrl -> write a NEW resource on master post-join ->
poll the spoke -> it shows up within a few seconds via the resync push,
no manual re-join needed. MULTISITE E2E PASS.

Unit tests: nodejs/tests/site_replicate.test.js (concurrent fan-out, one
failing spoke doesn't block another, empty-registry and list()-throws
edge cases).
2026-08-10 16:34:38 -04:00
wmantly 9d266d2e4c feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard
Completes the multi-site join layer on top of the v2.2.0 endpoints:

- UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing
  Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager
  (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe.
- POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master
  reachability probe for WAN health (cheap vs /export).
- Spoke read-only: directory-write routes (resources/edges/groups/secrets/
  grants/driver-action/discovered) reject with 403 pointing at the master.
- Fresh-install guard: /api/site/join refuses unless no users beyond the
  bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes
  canJoin so the UI only offers join on a genuinely fresh install. The
  bootstrap's seeded default resources are NOT the signal (they always exist).
- The spoke stores the join key (masterJoinKey) in /config/site.json so WAN
  health (and a future write-proxy) can reach the master.
- Tests: siteIsFresh cases in tests/site_join.test.js.
2026-08-10 09:12:28 -07:00
wmantly c96a4b6652 feat(site): multi-site join server endpoints + persisted site role + emoji fix
Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.

- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
  hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
  local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
  export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
  the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
  /config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
  LDAP bind credential from runtime config (never a hardcoded secret).
2026-08-10 05:58:28 -07:00
wmantly 8a0b796f81 test: update plugin and reconciler tests for 100% pass rate 2026-08-08 16:08:53 -04:00
wmantly a442dc9921 release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation 2026-08-08 15:38:35 -04:00
wmantly 10e5193077 test: update api_agent_ops test to match default paths 200 response 2026-08-07 23:35:20 -04:00
wmantly 181ca8c9cb Add LDAP-over-HTTPS API, agent secrets/IAM engines, and join key management
See CHANGELOG.md for the full breakdown. Summary:

- POST /api/v1/ldap/{bind,search}: LDAP-over-HTTPS so a client stops
  speaking raw LDAP and instead calls the SSO, which binds/searches its
  own OpenLDAP on the caller's behalf (DESIGN.md §3).
- LDAP byte-pump relay (utils/ldap_tunnel.js): forwards raw LDAP bytes
  from an agent's local socket into OpenLDAP over the existing agent WSS
  channel; the SSO never parses LDAP (DESIGN.md §4).
- POST /api/v1/agent/secrets: node-scoped OpenBao secret fetch for
  agents, enforced to each agent's own secret/data/nodes/<id>/* prefix
  (DESIGN.md §5).
- iam_apply signed command: push node-scoped IAM config (sudo rules, SSH
  keys, access control, revocation) to an agent (DESIGN.md §6).
- Agent capability badges on the Directory Metrics tab, sourced from the
  agent's own discovery frame.
- Join key management: GET /api/agent/join-keys/:id/agents (which hosts
  enrolled through a key) plus a Manage join keys table in the Install
  Agent modal with Revoke/Delete actions, confirmed inline per-row rather
  than a blocking native confirm() or the shared app.messages.confirm()
  banner (which desyncs across concurrent rows -- see CHANGELOG).
- docs/agents.md: capability matrix updated for the three new
  capabilities, a full secrets-engine walkthrough with screenshots
  (bash + Node consuming a rendered secret, plus the direct-API
  alternative), and the join-key reuse/UI/audit questions answered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:03:41 -04:00
wmantly f178f1a972 fix: test email/SMS senders, all SMS delivery, join-key install UI (v1.30.1)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m43s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 4s
Test Email always failed with "Email.send is not a function":
models/email.js exports {Mail}, and the handler required the module and
called .send on it directly. Every other caller destructures it.

Test SMS failed with "Unexpected token '<'": it POSTed to
https://api.voip.ms/v1.0/sms/send with Basic auth, an endpoint that does
not exist. VoIP.ms's REST API is a GET against voip.ms/api/v1/rest.php
with api_username/api_password and method=sendSMS, so the fabricated URL
returned HTML and response.json() threw.

Worse, ALL SMS delivery was broken. models/sms.js called
PluginInstance.find({...}) but the ORM has no find -- the query method is
list({where}) -- so it threw on every send, before it could even fall
back to the direct VoIP.ms path. OTP-by-SMS and notifications were dead.

Both test endpoints now send through the same senders every real message
uses. A test that reimplements delivery proves nothing about whether real
delivery works, which is how two broken paths went unnoticed. Failures
report as 400 with the underlying reason rather than an opaque 500.

Adds a guard suite that fails the build on any call to a non-existent ORM
static, on requiring models/email without destructuring {Mail}, and on
any reference to the bogus api.voip.ms host.

Also: the Install Agent modal now leads with the join-key flow. v1.30.0
shipped join keys in the API and documented the modal as the place to get
one, but the modal still only did the pre-register flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:51:37 -04:00
wmantly 87339da1b2 sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 23s
Pull Request Tests / Run Tests (22.x) (push) Failing after 37s
Pull Request Tests / Test Summary (push) Failing after 4s
SECURITY

/api/agent/ws authenticated nothing. There was no agent registry, so any
client reaching the SSO could register as a node, publish discovery and
telemetry into the admin view, and receive commands -- including a signed
arbitrary_bash -- addressed to a token it guessed. Tokens were generated
in the BROWSER and never recorded server-side, so there was nothing to
validate against and no way to revoke one.

Agents are now rows in a new Agent table, authenticated by SHA-256 token
hash before the connection is registered or the welcome payload is sent.
Tokens are minted by POST /api/agent/enroll and shown once. Revoke and
rotate drop the live socket immediately. All agent actions are audited.

The Ed25519 command-signing key was generated in the AgentManager
constructor, so it changed on every restart and the public_key pinned in
an agent's agent.yml stopped matching. It now lives in OpenBao at
secret/agent/signing-key; if it cannot be loaded the SSO refuses to send
high-risk commands rather than signing with a key no agent has seen.

DIRECTORY

Agents bind to a host resource instead of being matched by hostname, and
a bound agent's discovery is written onto that resource -- previously the
one source running ON the host contributed nothing to the directory.

The resource tree is collapsible, with state persisted per browser.

DISCOVERY

The Proxmox plugin zipped MACs and IPs from two flat lists by index,
attributing addresses to the wrong NIC on multi-NIC guests. NICs are now
keyed by MAC. Adds an endpoint resource parenting each node, sourceId/
vmid/node identity, container-interface filtering, node IP/MAC, and
offline-node handling.

The reconciler could make a resource its own parent, named hosts after
their MAC address, had a dead isIp() regex (\\. matches a backslash),
merged across kinds, and re-read the whole inventory per resource.

Dockerfile.test-runner never copied nodejs/plugins, so every plugin test
suite failed in CI as "Cannot find module".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:44:37 -04:00
wmantly e8d04203c3 fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168)
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
2026-08-04 23:00:25 -04:00
wmantly 8a9de94d24 release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166)
* feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0)

- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load
- map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice)
- enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal
- fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images
- fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width
- group resolver: keep the site slug verbatim (site_local not re-slugified)
- bump to 1.26.0

* fix: use verbatim resource slugs in group names (matches access-request tests + live convention)

The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)),
but the access-request tests + the live directory convention are verbatim
({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env).
For bare test slugs this produced site_x_host_artest-host_x_access instead of the
expected site_x_artest-host_x_access, so the requester was never removed from the
auto-provisioned access group and every request 409'd. resourceGroupCns is now
(site, slug, level) with the verbatim slug; the kind is used only to pick the
aggregate the group nests into.
2026-08-04 19:07:51 -04:00
wmantly 6d9c2f05ba feat: hierarchical group & permission model (v1.25.0)
- Add utils/groups.js: the group schema + inheritance resolver (god_admin,
  {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, per-resource
  admin/access/<capability>, meta everyone/{site}_everyone). admin implies
  access; capabilities explicit; hosts/apps orthogonal; cross-site isolated.
- permission.js: recognize god_admin (legacy app_super_admin aliased) and add
  onResource/requireResource for resource-level checks + everyone meta grants.
- user.js isAdmin: recognize god_admin + site-scoped super/app-admin groups.
- Remove the standalone Groups page (nav + route + view); groups are managed on
  adopted Directory resources. Add a /docs/groups help link in the Directory
  toolbar (GROUPS.md copied into the SSO docs).
- tests/groups.test.js: full resolver coverage (15 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:03:36 -04:00
wmantly dd24257640 fix vault 403 for real (v1.23.0)
- /api/vault proxy now injects X-Vault-Token: the proxy declared its request
  hook with http-proxy-middleware v3 syntax (on: { proxyReq }), which the
  installed HPM v2 silently ignores — so every vault call reached OpenBao
  unauthenticated (the recurring 403). Rewritten as v2 onProxyReq.
- Header injection ordered before fixRequestBody (the body write flushes
  headers; setting X-Vault-Token after it failed on every POST/PUT).
- initORM add-only schema heal: sequelize.sync() never ALTERs, so newer columns
  (PluginInstance.lastLog) are now added via describeTable + addColumn.
- Long-lived external-app tokens via sso-app role (768h periodic); VaultAppToken
  stores each app token's accessor and renews it at boot + every 6h; re-minting
  revokes the previous token via its accessor.
- Wire-level tests for the vault proxy + app-token accessor lifecycle.
- package.json + lockfile bumped to 1.23.0.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:11:51 -04:00
wmantly d8242b1d53 fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
Pull Request Tests / Run Tests (18.x) (push) Failing after 56s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 15:26:59 -04:00
wmantly b7aac2d2ba feat: Protocol v1.1.0 theta-agent C2 integration, agent install wizard, OpenBao 403 fix, nmap discovery fix, and restored documentation 2026-08-03 02:18:09 -04:00
wmantly 554a0999ab test: add tests for Proxy OpenBao configuration endpoint 2026-08-02 00:34:56 -04:00
wmantly cec0d92c25 feat: real plugin system with loadable instances + OpenBao secrets (v1.17.0)
Generalize the half-built discovery plugins into a real plugin system: plugin
TYPES (the plugins/<category>/<type>.js modules with manifests) and loadable,
configurable, multi-copy plugin INSTANCES (PluginInstance ORM model) managed
from a dedicated /plugins page and /api/plugins API, with per-instance secrets
in OpenBao at secret/plugins/<id>/conf.

- plugin_registry.js: getTypes/getModule/splitConfig/mask + required-field helpers
- PluginInstance model (Sequelize): id/pluginType/category/name/slug(unique)/
  enabled/cron/config(json, non-secret)/lastRun*; registered in models/index.js
- plugin_secrets.js: read/write/remove/mergeForRun over @simpleworkjs/bao-conf
- scheduler.js: schedules from the DB registry; per-instance stable BullMQ
  JobScheduler ids (plugin:<id>) for load/unload; legacy migration from
  conf.discovery.plugins on first boot (idempotent, empty-table-guarded)
- api_plugins.js (replaces routes/plugins.js): types/list/get/create/update/
  secrets/test/load/unload/run/delete/runs; admin-gated; secrets always masked
- /plugins page (plugins.ejs) + nav; Agents & Scheduler tab removed from
  /directory; /docs/agents aliased to /docs/plugins
- proxmox/unifi/nmap gained manifests (configSchema/validate/run alias)
- tests/plugins.test.js: registry unit + plugin_secrets (mocked bao-conf) +
  PluginInstance model round-trip/unique-slug
- docs (plugins.md, vault.md, _config.yml, API.md) + 1.16.1 -> 1.17.0

Requires theta-suite >= v1.30.1 for the sso-broker secret/plugins/* grant;
fails-soft with a clear error if absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 20:33:57 -04:00
wmantly 011d4b2975 feat(release): v1.14.0 discovery and conf pages 2026-08-01 01:21:27 -04:00
wmantly 4a592f9795 Release 1.11.0: end-user catalog, access requests, nested groups
Closes the end-user half of the directory and adds nested LDAP groups.

The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:

  - GET /api/discovery/me resolved groups from req.user.groups, which does not
    exist (req.user carries memberOf), so it returned only isPublic resources
    for every human caller -- "My Services" was blank for everyone. The same
    read made isDirectoryAdmin() false for real admins.
  - The portal's "Discover More Services" called the admin-gated endpoint and
    swallowed the 403, so it never rendered for non-admins at all.
  - Services reported no address, because /me had reimplemented getMyAccess
    without its parent-walking resolution.

Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").

Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.

member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.

Full suite green in both resolution modes: 215 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:22:08 -04:00
wmantly 0e955abc73 Standardize the resource modal: tabs, footer, linkable URL, Children tab, site-slug group prefixing (#120)
* Add Resource audit fields (created/updated by/on) and site-slug group prefixing

Resource had no created_by/created_on/updated_by/updated_on fields at all,
unlike proxy's Host and jump-host's ApiToken which already track this --
needed for the upcoming resource-modal footer. @simpleworkjs/orm has no
auto-timestamp hook, so these are set explicitly in the directory-admin
route handlers on every create/update.

Also: when a host/service resource is created, its two auto-created LDAP
groups (<slug>_access/_admin) now get prefixed with the nearest ancestor
site's slug (via a new Resource.findAncestorSiteSlug walk), so groups from
different sites don't collide/look identical. Falls back to today's
unprefixed naming when a resource has no site ancestor.

Included the checked-in dev inventory.sqlite's ALTER TABLE for the new
columns, since @simpleworkjs/orm's sync() only creates missing tables, never
alters existing ones -- the raw model change alone would have broken every
Resource read/write against this file with "no such column: created_by".

* Migrate Resource modal onto app.modal's tabs/footer/URL, add Children tab

The Directory's resource modal was a separate, hand-rolled, always-in-DOM
Bootstrap modal, independent of the shared app.modal singleton -- migrating
it onto app.modal (now published with tabs/footer/url support in
@simpleworkjs/frontend 0.2.6) is the pilot for standardizing entity modals
across the stack.

- General/Details/Associated LDAP Groups/Children tabs, replacing the old
  single long form (Details keeps every kind-conditional container
  unchanged; toggleFormFields() didn't need to change at all).
- Footer shows created/updated by/on (via the new Resource audit fields)
  and the Save button; Groups/Children tabs are hidden in add-mode since
  they need an existing resource id.
- New Children tab lists a resource's existing children (reusing the
  already-loaded edges/resourcesById data, no new endpoint) and an "Add
  Child Resource" button that reuses openAddModal's existing preset-parent
  support. Folded the pre-existing generic "Relationships (Graph Edges)"
  section in underneath, under an "advanced" subheading, rather than
  dropping it or giving it a 5th tab of its own.
- GET /directory/:slug (mirroring the existing /users/:uid precedent) plus
  a client-side app.modal.deepLinkSlug() check makes a resource's modal
  linkable and directly loadable.
- Converted the groups/edges lists from jq-repeat to plain manual DOM
  rendering: jq-repeat's MutationObserver-based scope (re)registration for
  an element that's destroyed and recreated on every modal open runs
  asynchronously, so populating synchronously right after open() (as
  refreshGroupsUI/refreshEdgesUI must) raced it -- on the second and later
  opens, the old scope's destroy() ran after the new data was pushed onto
  it, silently discarding it. Manual rendering (matching the new Children
  tab) sidesteps the race entirely.
- The #res-name/#res-kind auto-slug handler is now bound via
  app.modal.on() (delegated) instead of directly -- a direct bind would
  have silently stopped firing after the first Add/Edit, since the modal
  body is rebuilt from scratch on every open().

Verified live against the running dev stack: tabs/footer/groups/children
all render and populate correctly (including on a second open, confirming
the jq-repeat race fix), the address bar updates to /directory/{slug} and
reverts on close, browser Back closes the modal via popstate without a
page reload, and a resource created under a Site gets correctly
site-slug-prefixed LDAP groups.
2026-07-28 17:42:05 -04:00
wmantly 0c2f38f0fe Fix: group membership changes didn't invalidate the User cache
routes/group.js's add/removeMember never called User.clearCache(), unlike
the isServiceAccount handling in routes/user.js (which does this
deliberately, with a comment explaining exactly why). isServiceAccount is
derived at User.get() time from app_sso_service_account membership and
cached for 5 minutes -- so adding or removing a user from ANY group via
this route left group-derived state (isServiceAccount, and by extension
anything else that reads memberOf off a cached User) stale for up to 5
minutes.

In production this manifested as a real user's account appearing to
"vanish": users.ejs's People tab filters out anything with
isServiceAccount truthy, so once that user's membership in
app_sso_service_account changed, they'd disappear from the tab anyone
actually looks at for up to 5 minutes -- looking exactly like data loss,
though the account was never touched. Found by investigating a live "lost
users" report: the account had isServiceAccount: 'yes' and was in fact
still fully present, just hidden.

This does not explain how the account came to be a member of
app_sso_service_account in the first place (unresolved -- possibly a
manual/accidental group-membership change via the Groups UI, which has no
guardrail against adding a real person to what's meant to be a marker
group for non-person accounts). It does fix a real correctness gap: any
admin group-membership change now takes effect immediately instead of on
a timer.

Verified against a real LDAP+Redis harness: the new test fails on the
unfixed code (stale isServiceAccount immediately after the PUT) and
passes with the fix. Full suite: 189/191 passing (2 pre-existing skips).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:44:11 -04:00
wmantly 3852e9ba62 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on directory.ejs's "Rotate Client
Secret" -- it froze the tab). Every call site across the app was removed
in favor of app.messages.action/confirm/toast and app.modal.open; this
static check (scans views/ and public/js|lib/js for bare alert(/confirm(/
prompt() calls) keeps a regression from shipping unnoticed the way the
oauth_client.js DELETE bug just did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:10:49 -04:00
wmantly 7f2c71299f Fix DELETE /api/oauth/client/🆔 client.remove is not a function
OAuthClient wraps @simpleworkjs/orm's Resource model, whose instance
delete method is .delete() -- not .remove(), which is what model-redis's
Table instances (e.g. this app's ApiToken, AuthToken) use. The DELETE
route called the wrong one, so every delete silently 500'd; the route's
try/catch turned it into a plain JSON error response rather than a thrown
exception, and the existing tests' cleanup-only delete calls (afterAll,
end of the rotate test) never checked the response status, so the bug
shipped unnoticed. The Directory Management UI was never affected --
routes/api_directory_admin.js's DELETE routes already used .delete()
correctly throughout.

Found and root-caused live against a real deployment's SSO API, then
reproduced and fixed against a local docker stack with a rebuilt image:
confirmed DELETE returned a genuine 500 before the fix and a real 200 +
404-on-subsequent-GET after.

Adds two dedicated tests (PUT and DELETE persistence, each verified by a
follow-up GET rather than trusting the mutating response alone), and
hardens the existing rotate test's incidental delete call with real
assertions. Verified the new DELETE test fails on the old code and
passes on the fix. Full suite (189 tests, real LDAP + Redis) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:03:34 -04:00
wmantly fc0d9104d0 Release 1.4.0: shared @simpleworkjs/* packages; fix discovery envelope drift + client_secret_hash leak
Rewire onto @simpleworkjs/directory-schema, /ldap, and /app-stack. The
directory discovery API now returns the {results} envelope via explicit
/resources, /resources/:slug, /graph, /me handlers and routes every read
through projectResource/projectResources, which unconditionally strips
client_secret_hash (and any /secret|password|privatekey/i key) and reduces
metadata to a public allowlist for non-admins — closing the leak where the ORM
serialized metadata wholesale. The dead routes/api_discovery.js (mounted after
the 404 catcher) is removed; ?group= now returns 200 instead of 404. user_ldap
+ group_ldap take escapeFilter/escapeDN + makeClient/withClient from the shared
ldap package (posix/write-side stays app-local; cert validation unchanged).
build_info unified to {buildVersion,buildHash,buildYear}; ldapts ^8.1.8. New
tests/discovery.test.js locks in the envelope + no-secrets guarantees. Lockfile
regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 16:39:10 -04:00
wmantly 0ee6825a01 fix: OAuth client API returned client_id: undefined; unknown id 500'd
The ORM Model.toJSON() serializes only schema fields, so the mapped
client_id/scopes/redirect_uris/... that OAuthClient.get() attaches to
the wrapped Resource were stripped from GET /api/oauth/client[/:id]
responses. client_id came back undefined; the theta-env bootstrap then
POSTed /api/oauth/client/undefined/rotate and got a 500, aborting stack
bring-up whenever proxy-secrets.js lacked a usable secret.

- OAuthClient.get() now emits an explicit public toJSON (client_id, name,
  slug, scopes, redirect_uris, allowed_groups, token_lifetime, is_valid),
  deliberately omitting client_secret_hash so it can't leak over the API.
- OAuthClient.get() null-guards Resource.get() (which returns null, not
  throws) and returns a clean 404 for an unknown/undefined id instead of
  crashing on r.kind.
- Regression tests: list/get expose client_id + hide the secret hash, the
  list-then-rotate bootstrap path, and unknown-id -> 4xx not 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:06:55 -04:00
wmantly 12da7140c2 test: dockerized test suite (openldap + redis + test-runner)
docker-compose.test.yml spins up the all-in-one OpenLDAP image, a
standalone Redis, and a test-runner that seeds the test user and runs
jest against them. globalSetup honors REDIS_URL; tests/setup.js
initializes the ORM and flushes test Redis keys before the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly c4d7a1a8e9 feat: actionable metrics, LDAP log parsing, UI updates 2026-07-22 21:58:06 -04:00
wmantly b4fa824609 feat: configurable LDAPS hostname (ldapsHost/ldapsPort) and extensive docs (#89)
Add conf.ldap.ldapsHost / conf.ldap.ldapsPort so the /integrations page
can advertise an internal-only LDAPS hostname separate from the public
OAuth issuer. This avoids forcing admins to port-forward 636 publicly.

- routes/index.js derives LDAPS URL from ldapsHost/ldapsPort with issuer fallback
- integrations.ejs adds a contextual help panel explaining TLS hostname
  validation, the public-issuer default, and recommended internal-DNS /
  Docker-internal alternatives
- conf/base.js, secrets.js.example, DEPLOYMENT.md, docs/configuration.md,
  and docs/ldap.md document and expose the new options
- Add tests/integrations.test.js for default and custom ldapsHost behavior
- Bump version to 1.1.17

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 01:13:43 -04:00
wmantly bc5bca2e28 Add CI (Jest against the real bundled image); fix ppolicy pwdLockout default
- New GitHub Actions workflow: builds the real Dockerfile.openldap
  image, starts it, seeds the LDAP fixtures the test suite expects
  (uid 'test' + 'wmantly', matching the existing "wmantly is always
  present in the test LDAP" assumption in several test files), then
  runs the full Jest suite against it on Node 18/20/22. This repo
  previously had unit tests but no automated workflow running them.
- Found while building this: the bundled default ppolicy entry
  (docker-entrypoint.sh + ops/ldap-setup.sh) sets pwdLockout: FALSE,
  which is backwards -- it silently makes the admin "deactivate user"
  action a no-op for auto-lockout-after-failed-attempts (a related
  but distinct ppolicy feature from pwdAccountLockedTime). Fixed to
  TRUE in both places; ldap-setup.sh also gets a drift-correction
  path so an existing deployment can pick up the fix by re-running it.
- Separately, deactivating a user still doesn't block their LDAP bind
  in the bundled image even with this fix -- filed as #68, since it's
  a deeper OpenLDAP ppolicy overlay question unrelated to the CI/test
  setup here. tests/user_admin.test.js now soft-skips that specific
  assertion (with a console warning pointing at #68) instead of
  failing, so this known environment gap doesn't block CI.
2026-07-16 16:54:05 -04:00
wmantly 4c6b1e38b1 Support wildcard redirect_uri patterns for OAuth clients
theta42/proxy fronts an arbitrary number of hosts behind SSO, each with its
own callback URL (https://<host>/__proxy_auth/callback) — proxy's own code
comment already assumed "a wildcard redirect URI covers all", but no
wildcard matching existed here, so every proxied host's callback had to be
registered on the shared OAuth client individually or /oauth/authorize
would reject it with InvalidRedirectURI.

Add `*` (one hostname label) / `**` (any number of labels) wildcard support
to redirect_uri matching, e.g. `https://**.example.com/__proxy_auth/callback`
now covers every host proxy fronts under example.com. Exact matches still
work exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:42:32 -04:00
wmantly f45349e0cd Fix uid/gidNumber allocation crash, add a configurable id floor (#44)
Reported: creating any user via the API failed with

  {"name":"InvalidSyntaxError","message":"gidNumber: value #0 invalid per syntax Code: 0x15"}

Root cause: addPosixGroup() computes the next gidNumber as
`Math.max(...groups.map(i => i.gidNumber)) + 1`. theta-env's
bootstrap.js creates the first admin via raw ldapadd with a hardcoded
uidNumber/gidNumber (10000) directly on the user entry, but never
creates a matching posixGroup entry -- so on a theta-env-bootstrapped
directory there are zero posixGroup entries, `Math.max()` on an empty
array is `-Infinity` in JS (not 0), and `-Infinity + 1` stringifies to
"-Infinity" -- an invalid LDAP integer, rejected by the directory. This
broke every single user creation, not just this one.

Separately: the reporter's intended scheme is for organically-created
users to start at uidNumber/gidNumber 1500, distinct from the
bootstrap admin's reserved 10000. Fixing the crash with a bare "floor
of 1500" alone wouldn't achieve that, since addPosixAccount's own
Math.max() would still find the admin's posixAccount entry (uidNumber
10000, found via a different, correctly-indexed search) and allocate
10001 for the next user.

Added a shared nextPosixId(entries, key) helper: takes the highest
existing value strictly below conf.ldap.uidGidReservedFloor (default
9000) plus one, or conf.ldap.uidGidMin (default 1500) if there are no
such entries. Ids at/above the reserved floor -- like the bootstrap
admin's 10000 -- are ignored entirely when computing the next
available number, so real users always start at 1500 and grow upward
regardless of the admin's reserved id.

Verified against a real theta-env deployment end to end:
- Reproduced the exact reported crash on a fresh bootstrap
- After the fix: first real user gets uidNumber/gidNumber "1500",
  second gets "1501" -- admin's 10000 never enters the calculation
- New unit tests (nodejs/tests/posix_id.test.js, no LDAP required):
  6/6 pass, covering the empty-array case, the reserved-floor
  exclusion, and the NaN-from-missing-value case
- npm test: 18/18 passing tests still pass (unchanged); the other 155
  failures are pre-existing/environmental (no LDAP server in this
  sandbox) -- confirmed via git stash before starting this fix
2026-07-14 23:03:59 -04:00
wmantly bb79247054 oath grpup fixes 2026-07-02 16:49:22 -04:00
wmantly 93df047a21 oath fixes 2026-07-02 16:22:16 -04:00
wmantly c92dbe4aa6 Testing fix 2026-07-01 11:40:38 -04:00
wmantly 5644bfa5ec Updated frontend 2026-07-01 11:10:09 -04:00