The Permissions page only offered Delete, so changing a role or scope
meant removing the grant and re-adding it from memory.
A permission's id is derived from (subjectType, subject, scope, domain),
so changing any of those is a different record rather than an update. The
new PUT creates the new grant and removes the superseded one in that
order, so an edit can never leave the old grant behind still conferring
access.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-host "Allowed groups" field suggested only local groups,
permission subjects and conf.auth maps. None of those can ever match an
SSO-gated host: its allow-list is checked against the `groups` claim the
SSO issues (utils/host_sso.js), so only SSO groups are candidates.
Adds a conf.sso block (url + read-only apiToken, minted by theta-suite's
bootstrap) and a cached /api/group lookup merged into the suggestions.
Degrades silently to the previous local-only list when unset, and never
fails the request.
Authenticates with `Authorization: Bearer <token>` -- the SSO's
`auth-token` header is for browser session UUIDs and rejects a minted
API token.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- All pages now wrap their content in <div class="container mt-4">,
matching sso-manager-node's width instead of rendering full-bleed inside
the fluid shell.
- Users and Permissions pages converted from bare <table>s to the same
card-grid convention already used on the Groups page.
- Users backed by SSO/OIDC login (backing === 'oidc', set by the redis
user model's JIT-provisioning path) are now marked "External (SSO)" and
their password-change control is hidden; PUT /password/:username also
rejects with 403 server-side for such users. Deletion stays allowed.
Redis-backend only -- LDAP/PAM deployments have no per-record marker for
this today.
- app-base.js (byte-identical across the 3 apps): added
app.util.revealItem(), wired into the Users/Permissions create flows.
- Bumped @simpleworkjs/frontend to ^0.2.7.
Migrates proxy's hostModal (the modal this session's app.modal tabs/footer/
url support was originally modeled on) off its old always-in-DOM static
element and onto the shared app.modal component (@simpleworkjs/frontend
0.2.6), continuing the entity-modal standardization started with
sso-manager-node's resource modal.
Unlike the resource modal, this one already had 6 tabs and Host's audit
trail (created_by/created_on/updated_by/updated_on) already existed and was
already populated correctly by routes/host.js -- no model or route changes
needed there. The tab markup itself is kept as one hand-built bodyHtml
string rather than going through app.modal's own `tabs` array option: that
option builds the tab-content div itself, and there'd be no way to wrap a
<form> around just that piece without extending app.modal again, whereas
this modal's tabs already are exactly the pattern app.modal's own tabs
option was modeled on.
Key correctness points, found and handled:
- The one JS binding that was unsafe under DOM rebuild -- a `keyup` handler
driving the Let's-Encrypt challenge-type/wildcard visibility, previously
bound once directly against a captured selector -- is now delegated via
app.modal.on(), the same bug class already found and fixed in the
resource modal.
- hostLoadAuthSuggestions() (fills the SSO allow-list autocomplete
datalists) now re-runs on every modal open, not just once at page load,
since the datalists are rebuilt fresh (empty) each time.
- .actionMessage moved from a sibling of the old modal's <form> into a
descendant of it: formAJAX's error/success target resolution
(app.messages.action's closest('div.card') then a find('.actionMessage')
fallback) only succeeds via the fallback path once app.modal owns the
DOM, since app.modal's .modal-content carries no 'card' class.
- The footer's audit dates needed the explicit 'x' (unix ms) format token
for moment() -- Host's created_on/updated_on come back as redis-hash
strings, and moment's bare fallback parser silently produces "Invalid
date" for a numeric string without it (this app's own hostParseRow
already does this correctly elsewhere; the new footer code needed the
same treatment).
Also adds GET /hosts/:host (mirroring sso-manager-node's /directory/:slug)
plus a client-side deep-link check, threaded through a new onLoaded
callback on hostPopulate().
Verified live against the running dev stack: all 6 tabs render and save
correctly; the footer shows real created/updated-by/on dates; the SSO
autocomplete has options on a second modal open (not just the first);
the challenge-type keyup logic fires correctly on a second-ever modal
open (confirmed via the actual GET /api/host/wildcard-parent/... network
request); the address bar updates to /hosts/{host} and reverts on close;
a direct load of /hosts/{host} auto-opens the right host's modal; and a
real save (PUT) closes the modal and live-updates the row via the existing
pubsub subscription, end to end.
Same swap as sso-manager-node/jump-host: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm (real HTML-escaping, toast fallback); vendored val.js replaced by
the package's app.validate.js.
proxy's host/target/hostname validation rules (mirrored from the backend's
utils/hostname_validate.js — wildcard DNS patterns, not something other
apps need) move to public/js/app.js, registered via $.validateSettings,
since they're proxy-specific and don't belong in the shared package's
generic rule set (eq/user/password/ip).
app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as sso-manager-node's PR.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rewire onto the shared @simpleworkjs/oidc-client, /ldap, and /app-stack
packages (deleting the byte-identical local forks of the same code), close the
LDAP filter-injection in User.get by routing the username through escapeFilter
(RFC 4515), align model-redis ^1.6.0 and ldapts ^8.1.8, and unify build_info to
{buildVersion, buildHash, buildYear}. package-lock regenerated from the npm
registry (no file:/link:), so npm ci is clean in docker builds.
Co-Authored-By: Claude <noreply@anthropic.com>
- Replace isomorphic-dompurify with xss to avoid ESM-only jsdom/htmlparser2
transitive deps that break the existing Jest test suite.
- Keep rendered docs HTML sanitized via xss() in routes/docs.js.
Co-Authored-By: Claude <noreply@anthropic.com>
- Add isomorphic-dompurify to sanitize rendered docs HTML
- Tighten SocketServerJson socket permissions from 777 to 660
- Keep package.json version at 1.1.16
Co-Authored-By: Claude <noreply@anthropic.com>
- New docs/concepts-{hosts,dns,access,api-tokens}.md -- plain-language
guides aimed at less technical readers, each linking onward to the
existing system-design-level doc for anyone who wants that detail.
Card help links (Proxy List, Add/Edit host, DNS Provider cards,
Users/Permissions/Groups cards) now point here instead of straight at
Installation/Architecture.
- The "New API Token" card had no help link at all -- added, pointing to
the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
garbled heading + stray <hr> at the top: Jekyll front matter (meant
only for the GitHub Pages build) was never stripped before being
handed to the markdown renderer.
- Fixed cross-doc links never resolving in-app, since this viewer serves
docs at /docs/<slug> with no .html suffix: rewritten to the correct
in-app URL, first by registered slug, falling back to the doc's real
filename (the correct, working link form on the Jekyll/GitHub Pages
build) -- same idea as the existing image-path fix, and lets one link
written in a doc work on both targets.
Bumps to v1.1.13.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
- The edit form's "Parent Wildcard" option stayed greyed out even when a
valid wildcard existed, since hostEditOpen() never ran the eligibility
check (only the host field's keyup handler did, which setting .val()
programmatically doesn't fire) -- and the check itself, GET
/host/lookup/:item, had the same self-match bug as the recently-fixed
Host.prototype.update() case: it resolves an already-existing host to
its own record instead of a sibling wildcard. Added a dedicated
/host/wildcard-parent/:item route combining lookUp() (handles a
brand-new subdomain) with lookUpWildcardParent() (handles an
already-existing host), and hostEditOpen() now actually runs it.
- Migrated ops/nginx_conf/autossl.conf's deprecated "listen ... http2"
directive to the standalone "http2 on;" directive (nginx 1.25.1+).
Bumps to v1.1.12.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
- A ? icon in the top-right header deep-links to the doc most relevant to
the current page (client-side path mapping, same pattern already used
for top-nav active-link highlighting -- no server-side "current section"
local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
allowlisted doc set. No new dependency, stays usable with no internet
access.
Bumps to v1.1.10.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
<title>, the navbar brand text, and the logo were all hardcoded
"Proxy - Theta 42"/"Dynamic Proxy". New conf.name/conf.logo keys
(defaults matching current text/asset) thread through the existing
values object pattern in routes/render.js and routes/docs.js, and
top.ejs now renders <%- name %>/<%- logo %> for the title and a new
navbar logo image.
Footer (copyright, theta42.com link, GitHub/license links) and the
existing favicon.svg are left as-is -- open-source attribution and a
distinct, already-working icon asset, not deployment branding.
GitHub Releases already carried real changelog notes per tag, but
those require internet access to view -- exactly what the /docs
route exists to avoid. CHANGELOG.md is a committed, Keep-a-Changelog
style file (backfilled from the v1.1.0/v1.1.1/v1.1.2 release notes),
linked from README and served at /docs/changelog alongside the rest
of the project's docs.
GitHub Releases already carried real changelog notes per tag, but
those require internet access to view -- exactly what the /docs
route exists to avoid. CHANGELOG.md is a committed, Keep-a-Changelog
style file (backfilled from the v1.1.0/v1.1.1/v1.1.2 release notes),
linked from README and served at /docs/changelog alongside the rest
of the project's docs.
Public route reading from disk on every request with no throttling
-- add a per-IP limiter matching the routes/auth.js/routes/host.js
convention already used elsewhere in this repo.
Air-gap:
- DynamicRecord.refreshAll() called getPublicIp() (api.ipify.org,
icanhazip.com, ifconfig.me) every 4h on a timer regardless of
whether any dynamic records were configured -- the one background
call in the repo not actually gated by feature use. Now skips the
lookup entirely when there's nothing to refresh.
- Removed the stray, unauthenticated GET /test page (a leftover
jq-repeat demo) that loaded jQuery + Mustache from external CDNs.
- Removed a dead IE<9-only html5shim script tag pointing at a domain
that no longer resolves.
Docs:
- New GET /docs (index) and /docs/:slug routes render this project's
own README, DEPLOYMENT, api.md, and docs/*.md server-side via
marked (new dependency) -- so the documentation is readable from
the running app with no route to GitHub Pages, where it otherwise
only lives. Public, no auth, same tier as the health endpoint.
- .dockerignore/Dockerfile updated: docs/, DEPLOYMENT.md, and
nodejs/api.md were previously excluded from the image entirely
("served via GitHub Pages, not from the image") -- now copied in
alongside README.md/tos.md-style, since they're needed at runtime.
ops/backup.sh snapshots Redis (BGSAVE, dynamic RDB path lookup) and
./config for standalone deployments, with retention. A background
service polls GitHub releases every 24h and surfaces an admin-only
banner in the UI when a newer version is published.
CodeQL flagged POST/PUT/DELETE /api/host* as missing rate limiting despite
performing authorization -- same authLimiter pattern routes/auth.js already
uses, applied here with a higher ceiling since legitimate admin work (bulk
edits) is expected on these routes.
CodeQL also flagged utils/basicauth.js's SHA-1 hashing as reachable from the
new basicauth-user route -- this is the existing, documented htpasswd-
compatible {SHA} scheme (see the comment on hashPassword), not something
this PR changes; left as-is per that comment's existing "follow-up" note,
since swapping it requires a coordinated change to
ops/nginx_conf/hostfeatures.lua's verification and a migration path for
already-stored hashes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Auth tab is now a single choice (Off / Basic auth / SSO) instead of two
independent toggles that could both be on at once, which made it
ambiguous which gate actually protected a request. Enforced both in the
UI and server-side (POST/PUT), accounting for partial PUT updates against
the existing record.
- Add per-user basic-auth management (change password, delete) so an admin
no longer has to blow away and retype the whole user list to remove or
rotate one account.
- Fix: `Model.errors.ObjectValidateError(...)` is a constructor and was
being called without `new` everywhere in this codebase. Without `new`,
`this` inside it was the module's shared `errors` object (mutated in
place) and the call evaluated to `undefined` — so every
`throw Model.errors.ObjectValidateError(...)` actually threw `undefined`,
which Express's `next(undefined)` treats as "no error" and silently
falls through to the catch-all 404 handler. Every host/user/group/
permission/dns-provider validation error (bad hostname, bad IP, etc.) was
showing a confusing "Page not found" instead of the real message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Nav bar already showed the logged-in user's name linking to /profile
(cl-username); just removed the separate "Profile" and "API Tokens"
nav items now that Profile covers both.
- Merge api_tokens.ejs into profile.ejs as a section below the existing
profile card. /api-tokens 301-redirects to /profile.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Personal access tokens so scripts/CI can call the management API without an
OIDC browser session. Each logged-in user mints their own token; it
authenticates as the creator (groups snapshotted at mint, mirroring the proxy's
browser AuthToken), and the existing authz layer (Permission.effectiveFor /
roles.resolveEffective) applies unchanged. Local groups and owned-domain rights
are recomputed live; only SSO/LDAP group membership is the mint-time snapshot.
- models/api_token.js: new ApiToken model (prx_<id>_<secret> format; id is the
lookup key, secret bcrypt-hashed + isPrivate, shown once). add()/rotate()/
authenticate(); optional expires_at; best-effort last_used_on; groups
snapshot. No _ttl (persists). Deliberately NOT wrapped in ModelPs (so the
last_used_on write on the auth path doesn't spam the socket).
- routes/api_token.js: self-service CRUD (list/get/update/delete/rotate),
owner-scoped (created_by === reqUsername(req), 403 otherwise).
- middleware/auth.js + models/auth.js: accept `Authorization: Bearer prx_...`
(precedence over the auth-token session header). Builds a synthetic req.token
that satisfies the only three req.token reads (auth.js .user/.groupsArray,
authz.js reqUsername .created_by) so the authz layer works unchanged.
checkApiToken collapses every failure to one generic 401 (no leak).
- views/api_tokens.ejs + routes/render.js (GET /api-tokens): self-service page
(forceLogin, no admin gate) — create (token shown once), rotate, revoke.
- views/top.ejs: "API Tokens" nav entry visible to all logged-in users.
- public/js/app.js: app.apiToken client module.
- DEPLOYMENT.md + docs/docker.md: API tokens section.
Co-authored-by: Claude <noreply@anthropic.com>
- Default the "Parent Wildcard" challenge type when a wildcard parent exists.
- Split Authentication (basic auth + SSO) into its own tab; Access keeps IP
allow/deny.
- Add GET /api/host/auth-suggestions (authenticated host editors, not just
admins) and datalist-backed "type to search + Add" pickers for the SSO
allowed-users/groups lists.
Verified in a browser (tab present, datalist populated, picker appends deduped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both were dead/incomplete: POST /api/user/key called a nonexistent
User.addSSHkey, and the invite flow (POST /api/user/invite, User.invite,
User.addByInvite, InviteToken) had no consumer or UI. Drop the routes, the
InviteToken model, and the per-backing invite methods.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds opt-in basic auth per Host, following the existing per-host controls
pattern:
- Host fields basicauth_enabled / basicauth_realm / basicauth_users
({user: base64(sha1(pw))}). Credentials are parsed to plaintext by the pure
host_features normalizer and hashed at the route layer (utils/basicauth.js),
so plaintext never reaches Redis.
- ops/nginx_conf/hostfeatures.lua enforces it in access phase: verifies the
Authorization header against base64(sha1(password)), fails closed with a 401
WWW-Authenticate challenge.
- hosts.ejs gains an enable toggle, realm, and a username:password textarea
(passwords never echoed back; blank keeps the current set).
Unit tests cover hashing (matches the htpasswd {SHA} vector), credential
parsing, and normalization. Note: the Lua path needs verification on a live
OpenResty box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "can't log in with new credentials": routes/user.js POST called
User.add, which doesn't exist on the redis User model (it has create) — so every
API-created account threw and was never persisted. Switch to User.create and make
the Add button a submit.
Replace the broken password rule (rejected strong "@123Caplowercase", accepted
weak "lowercase1") with a clear policy in utils/password_policy.js: >= 8 chars and
either 12+ chars or 3-of-4 character classes. Enforced server-side on create and
password changes, mirrored in public/lib/js/val.js, with unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend (utils/hostname_validate.js, enforced in routes/host.js on create/update):
- host: IPv4 or a wildcard pattern whose labels may be normal, "*" (one
fragment) or "**" (any depth, incl. a bare "**" catch-all) — matching
Host.lookUp. Lowered Host.host min length to 1 so "**"/"*" pass the model.
- target (ip): IPv4 or a strict hostname, no wildcards.
- Both reject scheme, "/", ":" and whitespace; 422 with per-field keys.
Frontend (val.js) mirrors the rules: host/target validators + hosts.ejs fields
point at them. Unit tests in test/unit/hostname_validate.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename Grant -> Permission end-to-end (model, routes, view, frontend,
bootstrap) and add an idempotent redis migration for existing records.
- utils/roles.js: glob domain matching (* = one label, ** = any depth) against
the full host; authz passes the full hostname.
- Local groups: LocalGroup model + admin routes/UI; membership merged into
Permission.effectiveFor so app groups behave like SSO groups.
- Subject autocomplete via GET /api/permission/subjects (users + derived groups).
- User profile page (/profile) and username in the navbar; /api/user/me now
returns merged/local/external groups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
For deployments on WAN DHCP, operators can declare A records in the DNS section
that the app updates to this box's current public IP every 4 hours (and
immediately on create).
- utils/public_ip.js: getPublicIp() queries external echo services (ipify +
fallbacks, configurable) with pure isIPv4/extractIp helpers.
- utils/dns_records.js: pure planARecordUpdate() reconciliation decision.
- models/dns_provider.js: Domain.upsertARecord(name, ip) — provider-agnostic
upsert via getRecords + deleteRecordById + createRecord (createRecord alone is
not a reliable cross-provider upsert). Apex ('@') handling added to each
provider (CloudFlare uses the domain name, Porkbun an empty name, DigitalOcean
'@') via a new DnsApi.apexName().
- models/dynamic_record.js: DynamicRecord model (deterministic id per host,
apply()/refreshAll()), registered + ModelPs-wrapped for live UI updates.
- services/dynamic_dns.js + conf: 4h scheduler mirroring host_scheduler.
- routes/dns.js: /dynamic CRUD + /dynamic/ip, gated to domain managers/admins.
- views/dns.ejs: "Dynamic A Records (WAN IP)" card with add form + list.
- test/unit/dynamic_record.test.js: public-IP parsing + reconciliation logic.
Verified end-to-end against a live Porkbun domain (create, idempotent, IP-change,
cleanup) plus unit suite (111 pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every proxied request flows through one shared OpenResty location whose
behavior is chosen at request time from the host's Redis hash. Add per-host
controls as new Host fields enforced in Lua rather than static nginx config
(which can't key off a per-request variable):
- Rate limiting: per-client-IP token bucket via resty.limit.req
(ratelimit_enabled/rate/burst), backed by a new `ratelimit` shared dict.
- Response caching: opt-in per host via a global proxy_cache zone gated by
$skip_cache (respcache_enabled). Off by default; upstream Cache-Control
still honored.
- Custom/security headers: req_headers (upstream) + resp_headers (client) and
hsts_enabled, applied in access/header_filter phases.
- IP allow/deny CIDR lists via resty.ipmatcher (deny wins; non-empty allow is
default-deny).
New ops/nginx_conf/hostfeatures.lua holds the enforcement; proxy.conf's
access_by_lua string becomes a block that calls it, plus a header_filter block.
nodejs/utils/host_features.js is the pure, unit-tested normalize/validate layer
(header/CIDR parsing, range clamping, injection-safe values) applied in
routes/host.js and mirrored by the hosts.ejs edit form. install.sh gains the
ipmatcher rock, the cache dir, and the hostfeatures.lua symlink.
Per-host cache TTL is intentionally deferred (global default only) — see the
plan's limitations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Open redirect / client-side XSS (app-base.js): the post-login `redirect`
read from the URL fragment was assigned straight to window.location. Add a
same-origin guard (safeInternalPath) that rejects absolute URLs,
protocol-relative "//host"/"/\\host", and scheme targets like
"javascript:". Apply it in consumeTokenFragment and logInRedirect.
- Server-side defense in depth: sanitize `redirect` when storing OidcState
and when building the callback fragment (utils/safe_redirect.js, shared +
unit-tested).
- Missing rate limiting: throttle the unauthenticated auth endpoints
(/login, /oidc/start, /oidc/callback) with express-rate-limit (60/IP/15m).
Set `trust proxy: 1` so req.ip reflects the real client behind OpenResty.
Adds test/unit/safe_redirect.test.js; unit suite 77 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Authentication previously implied full authorization: any valid token
could manage every host, DNS provider, domain, and user. This adds SSO
login and a per-domain rights model.
OIDC login (authorization_code + PKCE):
- conf.oidc + conf.auth blocks; clientSecret in (gitignored) secrets.js.
- utils/oidc.js (state/PKCE, code exchange, userinfo) using global fetch.
- models/oidc_state.js: short-lived state store, auto-expiring via
model-redis 1.5 per-key TTL.
- routes/auth.js: GET /auth/oidc/start + /auth/oidc/callback; JIT-provisions
a local user, mints an AuthToken carrying the SSO groups, hands the token to
the browser via a URL fragment. "Log in with SSO" button on the login page.
Authorization (groups + app overrides, per-domain, with ownership):
- models/grant.js + utils/roles.js (pure, unit-tested): effective rights from
conf.auth (admin users/groups, group->role map), Grant records
(user|group -> global|domain -> viewer|manager|admin), and ownership
(created_by). Roles rank admin > manager(owner) > viewer.
- AuthToken stores session groups; middleware/auth.js exposes req.groups.
- middleware/authz.js: requireAdmin, requireDomainRole(minRole, resolveDomain),
filterViewable. Applied across routes: host mutations need manager on the
host's domain; reads are filtered to visible domains; DNS providers, user
management, and grant management are global-admin-only; certs need viewer.
- routes/grant.js: admin CRUD for grants. Anti-lockout via conf.auth.adminUsers
plus migrations/grant_bootstrap.js.
Frontend: /me returns effective rights; nav gates Users/Grants to admins;
grants management page; OIDC token-fragment handling in app-base.js.
Tests: utils/roles and utils/oidc unit-tested (no redis); wired into the test
scripts. Full suite 89 pass. Also verified end-to-end against redis (grant
resolution, middleware allow/deny/403, list filtering) and the OIDC pure flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>