Commit Graph

219 Commits

Author SHA1 Message Date
wmantly fcc09d1e4b logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:08 -04:00
wmantly a83a5fd39a app.api.delete: accept the (url, data, callback) form formAJAX uses; defer the login-card reveal to DOM ready
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (the host/DNS delete buttons) landed its callback in
the data slot and never ran.

The login page's "reveal the card once we know you're logged out" branch
touched an element further down the same page, which threw when
isLoggedIn answered before the parser got there (it always did without a
stored token). It now runs on DOM ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 3a307c1563 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

proxy specifics:
- .group-required base rule added to styles.css; the admin nav items lost
  their inline display:none in favour of it.
- The brand link points at / instead of #.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly f4efdfb957 Release 1.3.0: adopt shared @simpleworkjs/* packages; fix LDAP filter injection
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>
2026-07-25 15:53:30 -04:00
wmantly ecdced16fc Release 1.2.2: fix load-balancing crash (wrong lua-resty-balancer module)
Every request to a host with additional load-balancing targets 500'd:
targetinfo.lua required 'resty.balancer.round_robin', which does not
exist in the lua-resty-balancer rock actually installed by the
Dockerfile/install.sh. That rock provides resty.roundrobin instead,
with a different constructor (roundrobin:new(nodes), not
:new() + :reinit(nodes)).

Verified end-to-end in a rebuilt image: requests to a load-balanced
host now return 200 and alternate across both backend targets, with
no Lua errors in the OpenResty log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:21:03 -04:00
wmantly 3f46a807e5 Correct version to 1.2.1: v1.2.0 was already tagged for load balancing
The load-balancing feature (merged in #172/#173) was tagged v1.2.0 on
GitHub, but nodejs/package.json and CHANGELOG.md were never bumped for
it. Backfill a 1.2.0 CHANGELOG entry and bump this release to 1.2.1
instead of 1.1.18, so package.json/CHANGELOG stay ahead of the highest
existing tag rather than behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:07:28 -04:00
wmantly bb1b84b56d Release 1.1.18: fix bootstrap admin lockout, refresh screenshots
models/user_redis.js hardcoded the bootstrap anti-lockout admin
username to 'proxyadmin2', while migrations/permission_bootstrap.js
grants the global-admin permission to conf.auth.adminUsers[0]. An
operator who customized adminUsers away from the default ended up
with a bootstrapped account that had no admin permissions -- a
silent lockout. user_redis.js now derives the bootstrap username the
same way permission_bootstrap.js does.

Also corrected a secrets.js.example comment that claimed the
bootstrap password defaults to the username itself (it actually
generates and logs a random password), and refreshed all README
screenshots against the current UI, including a new load-balancing
screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:04:26 -04:00
wmantly 4321826dc8 feat: Add multi-target load balancing support
Fixes #47.
- Added lua-resty-balancer to dependencies (Dockerfile & install.sh).
- Added 'targets' field to the Host model to hold additional targets.
- Updated the UI to allow inputting additional targets (IP:port).
- Updated targetinfo.lua to parse the additional targets and load balance between them and the primary target using resty.balancer.round_robin.
2026-07-21 00:48:43 -04:00
wmantly 7452ccd655 Bump version to 1.1.17; update CHANGELOG
Move the wildcard sibling-parent fix into its own 1.1.17 section (it is not
in the v1.1.16 tag) and bump nodejs/package.json + lockfile to 1.1.17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:27:48 -04:00
wmantly fcd97b12aa Fix attaching an existing sibling subdomain to a parent wildcard
Host.lookUpWildcardParent() walked all labels of the host down to its own
leaf and only inspected that leaf's "*" child, so it found a wildcard
nested under the host (the base-domain case, e.g. *.cool.mysite.com for
cool.mysite.com) but missed the common case where the wildcard is a
SIBLING of the host's leftmost label (e.g. *.nl.wgnode.com covering an
already-existing sso.nl.wgnode.com). The /wildcard-parent route then
returned nothing and the edit form's "Parent Wildcard" option stayed
greyed out, leaving no way to convert an existing auto-SSL host onto a
wildcard issued afterward.

Track the parent node during the walk and check the sibling "*" slot too.
The never-created-subdomain case is unchanged (plain lookUp()'s wildcard
fallback in the route still handles it). Sync the test mock and add
regression coverage for the sibling case (existing host, never-created
host via the route fallback, and a deeper-wildcard negative case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:13:13 -04:00
wmantly 6ede072213 security: swap sanitizer to xss for Jest-compatible SSR output
- 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>
2026-07-18 23:02:11 -04:00
wmantly bdaba513a7 security: sanitize markdown output and harden unix socket permissions
- 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>
2026-07-18 22:41:47 -04:00
wmantly d1dd40d60a chore(release): public-release readiness fixes for 1.1.16
- Fix MIT LICENSE copyright placeholder
- Remove private flag and correct GitHub repository URL in package.json
- Bump version to 1.1.16
- Genericize committed config defaults (example.com/localhost)
- Harden global error handler against information leakage
- Generate random initial password for proxyadmin2 bootstrap account
- Correct docs to describe CONF_SECRETS instead of symlink behavior

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:02:27 -04:00
wmantly f8d620f4d3 Bump version to 1.1.15; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:15:31 -04:00
wmantly a02ca4d3e7 Bump version to 1.1.14; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:42:00 -04:00
wmantly 355a9d68e5 Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
conf 1.2.0 adds CONF_SECRETS, an env var to point at the secrets file
directly -- use it in the Docker entrypoint instead of symlinking the
mounted file into /app/conf/secrets.js, so the app no longer needs
write access to its own conf/ directory to pick up mounted secrets.
jq-repeat 2.2.0 is a compatible feature release (sort(), replace(),
faster leading-edge update() timing); no call-site changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:35:24 -04:00
wmantly 426fa111ec Add plain-language concept docs; fix docs viewer rendering; link API tokens
- 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
2026-07-17 22:09:53 -04:00
wmantly 4f1fce367e Fix wildcard-parent edit greying and deprecated nginx http2 directive
- 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
2026-07-17 21:33:26 -04:00
wmantly a40da55993 Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics. Removed it and added a small help icon directly to each card
that has real corresponding doc content, linking straight to that doc:
Proxy List + Add/Edit host modal (hosts.ejs), Add DNS Provider + Dynamic
A Records (dns.ejs), Add New User + User List (users.ejs), Add
Permission + Permissions (permissions.ejs), Add Group (groups.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 20:00:34 -04:00
wmantly fcd73169e0 Add header help icon and in-app docs search
- 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
2026-07-17 19:28:07 -04:00
wmantly 9100e92549 Host form/list UX polish: editable hostname, created-by column, mobile tabs, more help text
- Plain hosts can now be renamed after creation (wildcard/child/cache hosts
  stay locked, since other records reference them by name). Migrates the
  cert cache key on rename.
- Along the way, found and fixed a real bug in the vendored model-redis
  library: its rename path leaves a stray, incomplete hash behind under
  the old key when an `always`-type field (updated_on) is defined earlier
  in the schema than the primary key -- silently blocking that hostname
  from ever being reused. Worked around at the Host model level (can't
  patch node_modules).
- Host list now shows who created each host, and when.
- Host modal's tabs now scroll horizontally on narrow screens instead of
  overflowing awkwardly.
- Added missing inline help text (Target SSL, wildcard matching behavior).

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:03:26 -04:00
wmantly 17b903e228 Fix two wildcard-cert gaps: attaching an existing host, and the wildcard's own base domain
- Host.prototype.update() had no challengeType handling (only create() did),
  so selecting "Parent Wildcard" on an existing host's edit form silently
  did nothing. Added the same wildcard-parent lookup to update(), using a
  new Host.lookUpWildcardParent() -- the existing lookUp() can't be reused
  here since an already-created host resolves to its own leaf rather than
  falling through to a sibling wildcard.

- A wildcard's issued cert covers both the base domain and *.base domain
  (altNames), but the lookup tree stores the wildcard one level below its
  base -- looking up the bare base domain landed on an empty parent node
  and found nothing. buildLookUpObj() now also stamps that parent node,
  order-independent (a real host explicitly created at that exact name
  always still wins).

Verified both fixes against a real Redis-backed Host model (not just the
mocked lookup-tree tests) -- see PR description.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 18:46:09 -04:00
wmantly b4d971b508 Bump version to 1.1.7; update CHANGELOG 2026-07-16 20:22:02 -04:00
wmantly 34b1413c96 Bump version to 1.1.6; update CHANGELOG 2026-07-16 19:02:59 -04:00
wmantly a57f3f03f6 hosts.ejs: fix Authentication tab radios not enforcing mutual exclusivity
The three auth_mode radios (Off / Basic / SSO) had no shared [name]
attribute, so per the HTML spec each was its own independent group --
clicking one didn't uncheck the others, letting multiple options
appear selected at once despite the page's own text saying "basic
auth and SSO can't both be enabled."

Added name="auth_mode" to restore native browser radio-group
behavior. The original comment claimed the radios were deliberately
kept nameless to avoid polluting the submitted form data (formAJAX
serializes every [name] field in the form), but that reasoning
doesn't hold: model-redis's processKeys() rebuilds the saved object
strictly from the Host model's own _keyMap, so an unrecognized
auth_mode field is silently stripped before anything is ever
persisted -- confirmed directly with model-redis's own
object_validate.js. Updated the stale comment accordingly.
2026-07-16 18:59:50 -04:00
wmantly 7c0cb5eabd Bump version to 1.1.5; update CHANGELOG 2026-07-16 18:34:03 -04:00
wmantly 6cd3a5bc58 Update jq-repeat to 2.1.0; fix removed __setPut/__setTake API
jq-repeat 2.1.0 (release notes: https://github.com/wmantly/jq-repeat/releases/tag/v2.1.0)
brings real fixes (throttled-update race conditions, sorted-list
reverse() leaking elements, nested-scope isolation) and a few
behavior changes. Audited every usage in this repo against the
changelog before upgrading:

- push()/unshift() now return the new array length -- every call
  site in this repo is a bare statement, none consume the return
  value. No risk.
- update() is now trailing-edge throttled (~50ms) even on the first
  call, not just rapid subsequent ones -- no code in this repo reads
  DOM/item state immediately after calling update(), so no risk here
  (unlike sso-manager-node's companion PR, which needed a fix).
- jr-order-reverse and nested jq-repeat templates: not used anywhere
  in this repo.

Real breakage found and fixed: users.ejs/groups.ejs/permissions.ejs
called $.scope.X.__setPut(fn)/__setTake(fn) as setter METHODS -- that
API is gone in 2.1.0. Insert/remove hooks are now set via direct
property assignment ($.scope.X.__put = fn), per the current README.
Verified live (real dev server + Playwright): before the fix, all
three pages threw "__setTake is not a function" and the
insert/remove row animations were broken; after, zero errors and the
hooks fire correctly.
2026-07-16 18:26:02 -04:00
wmantly 526545ee68 Bump version to 1.1.4; update CHANGELOG 2026-07-16 17:44:00 -04:00
wmantly fd71485960 White-label: title/logo now driven by conf (closes #45)
<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.
2026-07-16 17:35:49 -04:00
wmantly 17e9ef2783 Bump version to 1.1.3; update CHANGELOG 2026-07-16 16:02:59 -04:00
wmantly edf60b3e3d Add CHANGELOG.md, serve it in-app at /docs/changelog (closes theta42/theta-env#43)
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.
2026-07-16 15:59:16 -04:00
wmantly 1f10d0db14 Revert "Add CHANGELOG.md, serve it in-app at /docs/changelog (closes theta42/theta-env#43)"
This reverts commit 4bf1768529.
2026-07-16 15:59:01 -04:00
wmantly 4bf1768529 Add CHANGELOG.md, serve it in-app at /docs/changelog (closes theta42/theta-env#43)
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.
2026-07-16 15:58:26 -04:00
wmantly 8565f4aa27 Bump version to 1.1.2 2026-07-16 15:36:50 -04:00
wmantly c71b23ef82 docs.js: rate-limit the doc routes (CodeQL: missing rate limiting)
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.
2026-07-16 15:29:06 -04:00
wmantly 7d9c63b049 Air-gap fixes + in-app /docs (README/DEPLOYMENT/api.md/docs/*)
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.
2026-07-16 15:26:10 -04:00
wmantly f98c4ec44f Bump version to 1.1.1 2026-07-16 13:56:57 -04:00
wmantly 3e90062255 DuckDNS: validate token via a TXT write, not the A/AAAA record
listDomains() used to validate the token by calling DuckDNS's update
endpoint with ip/ipv6 omitted, which makes DuckDNS auto-detect and
apply this host's public IP -- so adding a provider instantly
repointed the domain. Validate via a fixed TXT marker instead, which
DuckDNS's API supports independently and doesn't touch routing.
2026-07-16 12:55:06 -04:00
wmantly 22274eddbe Bump version to 1.1.0 2026-07-15 22:38:00 -04:00
wmantly 1df9916c91 Add standalone backup script and admin update-check banner
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.
2026-07-15 22:32:31 -04:00
wmantly 1026f18c08 Rate-limit host-mutating routes
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>
2026-07-15 00:45:24 -04:00
wmantly a19ff81c76 Fix a worker-blocking Lua socket call and add gzip/caching for static assets
- ops/nginx_conf/targetinfo.lua's wildcard-subdomain lookup fallback used
  classic LuaSocket (require("socket.unix")) instead of an OpenResty
  cosocket. LuaSocket is blocking, and called from an nginx worker it
  stalls the ENTIRE worker — every other in-flight connection on it — for
  the round-trip to the Node app. Worse, the Node side never
  newline-terminated its response, so the old blocking receive() only ever
  returned via its read-timeout-then-partial-read fallback, meaning every
  single cache-miss lookup paid a fixed timeout penalty while blocking the
  whole worker. Replaced with an ngx.socket.tcp() cosocket (unix-domain via
  "unix:/path", the only cosocket API this lua-nginx-module ships) and
  newline-terminated the Node service's responses so receive() actually
  completes instead of timing out. Verified against a live container:
  previously this crashed OpenResty's Lua VM entirely
  (ngx.socket.unix doesn't exist); fixed version resolves fresh wildcard
  subdomains in ~2ms.
- Add gzip compression (`compression` middleware) and far-future
  Cache-Control on static assets (7d for vendor libs under
  /static-modules, 1h for the app's own /static JS/CSS, which isn't
  cache-busted). The admin UI is a traditional multi-page app that loads
  ~13 separate vendor/app JS+CSS files on every full navigation; previously
  none of them were compressed and Cache-Control was `max-age=0` (Express's
  default), forcing a revalidation round-trip for every asset on every page
  view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:41:31 -04:00
wmantly ad2cacf094 Make basic auth and SSO mutually exclusive per host; fix silently-broken validation errors
- 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>
2026-07-15 00:41:16 -04:00
wmantly 3f4f98ec4e Unify nav: drop Profile nav item, fold API Tokens into Profile page
- 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>
2026-07-14 23:44:05 -04:00
wmantly c68fcc9ddb Fix Docker sticky footer, commit hash, and configurable local admin password (#133)
* Fix TLS handshake failure for any host without a cached target

Reported: fallback SSL doesn't work in the Docker build. Reproduced —
it's worse than the fallback specifically: TLS was broken for nearly
every connection, including ones with no SNI at all:

  $ curl -vk https://127.0.0.1/
  * TLSv1.3 (IN), TLS alert, internal error (592)
  * OpenSSL/3.0.13: error:0A000438:SSL routines::tlsv1 alert internal error

Root cause: targetinfo.lua's M.get() is shared by two call sites in
two incompatible nginx phases —

  - proxy.conf's access_by_lua_block (a normal HTTP request phase,
    where ngx.exit() is valid)
  - nginx.conf's request_domain callback, which runs during the TLS
    handshake itself (ssl_certificate_by_lua*), where ngx.exit() is
    NOT a supported API

M.get() called ngx.exit() on every lookup failure (no domain/SNI, a
Redis error, or an unregistered host). When invoked from the SSL
phase, that aborted the handshake with a bare "internal error" alert
and produced no log output anywhere — silent and total, not limited
to the unregistered-domain case, since even a connection with no SNI
hits the same code path immediately.

Fix: M.get() no longer calls ngx.exit() itself — it returns
(nil, httpStatus) on failure. proxy.conf now checks the return value
and calls ngx.exit() itself (the phase where that's actually
supported). nginx.conf's request_domain guards the now-possibly-nil
result before indexing it, and leaves ngx.ctx.toAllow unset on
failure so allow_domain() correctly denies issuance and auto-ssl
falls through to the static fallback cert in autossl.conf.

Verified end to end against a running Docker build (deployed the
changed files into a live container and reloaded, rather than relying
on a full rebuild each iteration):
- No SNI at all: TLS now completes; HTTP layer correctly returns 406
  (previously: broken handshake, no response at all)
- Unregistered SNI: same — TLS completes, 406, and openssl s_client
  confirms the cert served is genuinely the fallback
  (CN=sni-support-required-for-valid-ssl)
- A real registered Host: TLS completes and proxies through to the
  backend correctly (confirms the success path is unaffected)
- npm test: 192/192 pass

* Fix footer not sticking to the bottom on short pages

body had no sticky-footer layout at all (sso-manager-node already had
this; proxy never did), so on any page with little content (e.g.
/login) the footer sat right after the content instead of at the
bottom of the viewport, leaving a large gap below it.

Added the same flex-based pattern already used in sso-manager-node:
body is a column flex container, #spa-shell grows to fill the
remaining space, pushing the footer (the next sibling) to the bottom.

Verified visually (screenshot) and via computed layout
(footer.getBoundingClientRect().bottom === window.innerHeight) before
and after.

* Fix commit hash not showing in Docker builds

build_info.js computed buildHash via `git rev-parse --short HEAD` at
runtime, but the final image intentionally has no git binary and no
.git directory (kept lean, per .dockerignore) — so this always failed
silently and the footer's version line showed "unknown" for every
Docker deployment. Working correctly only for bare-metal/dev, where
git + .git are actually present.

Added a throwaway gitinfo build stage that reuses the main base image
(no extra pull) with git installed just for this stage, reads .git
from the build context (now no longer excluded — see .dockerignore),
and bakes the resolved short hash into a small file that IS copied
into the final image. build_info.js reads that file first, falling
back to the old git-rev-parse behavior (still needed for bare-metal).

Verified against a real build: `docker exec proxy cat
/app/.build_commit` matches `git rev-parse --short HEAD` on the host,
and the footer now shows the real hash instead of "unknown".

* Allow the local anti-lockout admin's initial password to be configured

The local "proxyadmin2" bootstrap account was always created with
username == password == "proxyadmin2" — a hardcoded, publicly-known
default with no way to set it to something else before first boot.
Fine for a quick local test, not for anything exposed publicly, and
orchestrators like theta-env's setup.sh (which already generates a
random password for the SSO admin) had no way to do the same here.

Added conf.auth.localAdminPass (proxy-secrets.js / app_auth__localAdminPass):
if set, it's used as the initial password instead of the hardcoded
default. Only read on first creation — once the account exists this
is never consulted again, so it's safe to leave set. Falls back to
the previous behavior (password == username) when unset, so this is
fully backward compatible.

Verified: with app_auth__localAdminPass set, login with the new
password succeeds and the old default ("proxyadmin2") is correctly
rejected. Confirmed in a real Docker build too (secrets.js
auth.localAdminPass), and npm test 192/192 pass.

* Support GIT_COMMIT build-arg override for submodule builds

The gitinfo stage from the previous commit works for a standalone
clone (.git is a real directory) but not when this repo is built as a
git submodule (e.g. from theta-env): a submodule's .git is a pointer
FILE, not a directory — the real object database lives in the
superproject's .git/modules/, outside this repo's own directory and
therefore outside Docker's build context entirely. `git rev-parse`
can never resolve it from in here no matter what, so builds via
theta-env still baked in "unknown" despite the earlier fix.

Add an optional GIT_COMMIT build-arg that, when set, wins over the
in-context git resolution. theta-env's setup.sh now computes it on the
host (where the submodule DOES resolve correctly) and passes it via
docker-compose.yml's build.args.

Verified via theta-env's actual setup.sh end to end: rebuilding with
this change, `docker exec proxy cat /app/.build_commit` now matches
`git -C proxy rev-parse --short HEAD` on the host (previously:
"unknown", confirmed via the "[Warning] One or more build-args
[GIT_COMMIT] were not consumed" message before this fix synced into
the docker-compose.yml side).
2026-07-14 22:32:24 -04:00
wmantly 8aab9c7673 Wrap unresponsive tables in table-responsive (#131)
permissions.ejs (6 columns), users.ejs (3 columns incl. an inline
password-change form), and profile.ejs's domain-permissions table had
no .table-responsive wrapper, so on narrow/mobile viewports they'd
either overflow the page horizontally or force zoomed-out, unreadable
text instead of scrolling within the table. hosts.ejs already had the
wrapper — these three didn't.

Verified: EJS compiles, npm test 192/192 pass, and fetched each route
from a running instance to confirm the wrapper is present in the
served HTML.
2026-07-14 21:17:33 -04:00
wmantly 9d34ff96e2 Add a footer, move GitHub link out of the nav (#130)
proxy had no page footer at all — no copyright, no license link, no
build/version info — unlike sso-manager-node, which already had one
(now cleaned up in a matching change there). Bring the two in line:

- Added a footer to bottom.ejs: theta42 logo/link, "© <year> theta42"
  with an MIT License link, a GitHub link, and build version/hash.
- Moved the GitHub icon link out of the top nav (where it competed
  with actual navigation items) and into the new footer.
- Added nodejs/utils/build_info.js (buildVersion/buildHash/buildYear,
  read once from package.json + git) and wired it into routes/render.js
  so every page has the values the footer needs. Mirrors the same
  helper added to sso-manager-node in the matching cleanup there.
- Copied the theta42.svg logo into nodejs/public/img/ (previously only
  present in sso-manager-node) so both apps' footers render identically.

Verified by rendering top+bottom with the real ejs package: no
template errors, GitHub link present exactly once (in the footer, not
the nav), logo and MIT License link present. npm test: 192/192 pass
(unaffected — view-only + a new leaf utility module).
2026-07-14 20:55:11 -04:00
wmantly 73c0f85ff7 DnsProvider.create: fix Domain key mismatch and roll back on failure (#129)
Two compounding bugs, both hit while adding a DuckDNS provider:

1. Domain.get() normalized every lookup via tldExtract before
   checking Redis. model-redis' Table.create() stores the new record
   under the literal key it's given, then calls this.get() internally
   to return the created instance — so for any domain tldExtract
   doesn't recognize as a shared/public suffix (e.g. a DuckDNS name
   like "myhost.duckdns.org", which tldExtract naively normalizes to
   "duckdns.org"), that final read-back always missed and create()
   threw EntryNotFound, despite the record having just been written
   successfully. In other words: no *.duckdns.org domain could ever
   be created. Fixed by trying the exact string first and only
   falling back to the tldExtract-normalized parent if no exact
   record exists — preserving the original "look up an arbitrary
   hostname, find the Domain that governs it" behavior for real
   lookups, while fixing create()'s own read-back of what it just
   wrote.

2. create() saves the DnsProvider row first, then calls
   updateDomains() as a separate step. If updateDomains() throws —
   e.g. a Domain collides with a stale/orphaned record left over from
   an earlier failed attempt (exactly what bug 1 was silently
   producing) — the already-saved provider row was never cleaned up,
   leaving a broken, domain-less provider behind despite the API
   returning an error. Fixed by wrapping updateDomains() in its own
   try/catch and removing the provider on failure.

   That fix has its own subtlety: `instance` (from super.create())
   has its `domains` relation resolved by super.create()'s own
   internal get() call, which runs BEFORE updateDomains() creates any
   Domain rows — so instance.domains is permanently stale (always
   empty), on both the success and failure paths. Removing `instance`
   directly would delete the provider but silently leave behind
   whatever domains updateDomains() did manage to create. Fixed by
   re-fetching (this.get(instance.id)) before both the success return
   and the failure-path remove(), so relations are current in both
   cases — the returned/API-response instance and the rollback's
   cascade-delete.

Manually verified against a live Redis (this project's test
philosophy explicitly excludes Redis-ORM-dependent tests from the
automated suite — see test/README.md "Philosophy"):
- A pre-existing orphaned Domain (simulating bug 1's fallout) now
  produces an accurate "already exists" error instead of a confusing
  EntryNotFound for the wrong (normalized) domain name, and the
  failed create() leaves zero orphaned providers behind.
- A genuinely new *.duckdns.org domain now creates successfully,
  correctly links to its provider, and is fully cascade-deleted when
  the provider is removed.
- npm test: 192/192 pass.
2026-07-14 20:54:08 -04:00
wmantly e091c15d95 Fix DuckDNS double-suffixing a subdomain that already includes .duckdns.org (#128)
Reported error when adding a DuckDNS provider with
subdomains="nl-theta42.duckdns.org" (the full name, as DuckDNS's own
site displays it):

  {"name": "EntryNotFound", "message": "Domain:duckdns.org does not exists"}

listDomains() blindly appended ".duckdns.org" to whatever was entered,
turning "nl-theta42.duckdns.org" into
"nl-theta42.duckdns.org.duckdns.org". tld-extract doesn't know
duckdns.org is a shared suffix, so it parsed that malformed string
down to domain "duckdns.org" — surfacing as a confusing EntryNotFound
two layers away from the actual cause (Domain.create's internal
lookup).

Add __normalizeLabel() to strip a trailing ".duckdns.org" (and
lowercase) before use, so both "myhost" and "myhost.duckdns.org" work
identically. Also make __label()'s existing suffix-strip
case-insensitive to match.
2026-07-14 12:29:06 -04:00
wmantly 1c7ad9aaae Fix DuckDNS domains field colliding with DnsProvider's own relation (#127)
Reported error when adding a DuckDNS provider:

  TypeError: this.domains.map is not a function
    at Proxy.updateDomains (models/dns_provider.js:185:37)

DnsProvider.__intraModel merges `{...DnsProvider._keyMap,
...Provider._keyMap}`, so a provider-defined field with the same name
as one of DnsProvider's own (created_by, updated_by, name,
dnsProvider, domains, id) silently overwrites it. DuckDNS defined a
`domains` field (the operator-supplied comma-separated subdomain
list), which replaced DnsProvider's `domains` relation (rel: 'many' to
Domain, populated by updateDomains()) — so `this.domains` stopped
being the array relation and became DuckDNS's raw string instead.

Rename the field to `subdomains` throughout (model, docs, tests). Add
a comment on __intraModel documenting the collision risk for future
providers, and a regression test asserting no registered provider's
_keyMap redefines one of DnsProvider's reserved field names.
2026-07-14 01:24:53 -04:00