Commit Graph
100 Commits
Author SHA1 Message Date
wmantly c6b5479a94 Merge pull request #137 from theta42/marketing-pages
Turn GitHub Pages into a marketing landing page
2026-07-15 16:09:44 -04:00
wmantlyandClaude Sonnet 5 547aa1ea55 Turn GitHub Pages into a marketing landing page; cross-link, drop download buttons
- Rewrite docs/index.md as a short landing page (what it is, screenshots,
  why this over the alternatives, features, a minimal "get it" snippet)
  instead of a full documentation dump — full docs live in the repo
  (README, docs/*.md) and are linked from here.
- Cross-link to SSO Manager and theta-env's own Pages sites.
- Screenshots are now clickable (open full size) on both the Pages site
  and the README.
- Disable show_downloads in docs/_config.yml — the Cayman theme's
  "Download .zip/.tar.gz" buttons are gone; "View on GitHub" (which links
  back to the repo) is the only header link now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:06:14 -04:00
wmantly 3b9f587664 Merge pull request #136 from theta42/docs-screenshots
Add screenshots to README and docs site
2026-07-15 15:44:51 -04:00
wmantlyandClaude Sonnet 5 9fcb7c0246 Remove unused permissions.png screenshot
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:41:22 -04:00
wmantlyandClaude Sonnet 5 c3cfd41a80 Add screenshots to README and docs site
Captured from a fresh theta-env install with demo data, via headless
Chrome + Playwright (scripted login, no manual UI interaction needed to
reproduce).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:41:12 -04:00
wmantly ac20fb4bfd Merge pull request #135 from theta42/auth-exclusivity-and-perf
Mutually-exclusive host auth, fix hidden 404 validation bug, and worker-blocking Lua fix
2026-07-15 00:48:23 -04:00
wmantlyandClaude Sonnet 5 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
wmantlyandClaude Sonnet 5 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
wmantlyandClaude Sonnet 5 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 71f1b12a74 Merge pull request #134 from theta42/nav-unification
Unify nav: drop Profile nav item, fold API Tokens into Profile page
2026-07-14 23:48:37 -04:00
wmantlyandClaude Sonnet 5 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 22f382b968 Fix TLS handshake failure for any host without a cached target (#132)
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
2026-07-14 21:33:48 -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
wmantly a9dfebc481 Allow single-label hostnames as a Host target (#126)
The target ("ip") field validator required at least two dot-separated
labels, rejecting legitimate single-label hostnames like Docker Compose
service names ("sso-manager"), /etc/hosts entries, or anything resolved
via a search domain. This was enforced identically client-side
(public/lib/js/val.js) and server-side (utils/hostname_validate.js,
routes/host.js), so there was no way to set one through the UI or API
— only by writing to the Host model directly, bypassing validation
entirely (which is how theta-env's setup.sh registers sso-manager as a
target today, working only because it calls Host.create() directly).

Relax HOSTNAME in both places to accept either a bare single label or
the existing dotted-FQDN pattern. Flips the one existing test that
codified the old behavior (isValidHostname('localhost') was asserted
false) and adds coverage for the reported case.
2026-07-14 01:12:02 -04:00
wmantly f926d92f3a README: rewrite with feature overview, comparison, and tiered quick start (#125)
Expands the README with a fuller feature description, a "why this over
the alternatives" comparison against Nginx Proxy Manager/Traefik/Caddy/etc,
and a three-tier quick start (unified theta-env stack, standalone Docker,
bare metal) instead of the single "Quick Install" section.
2026-07-14 00:21:05 -04:00
wmantly d5df5baca1 Add DuckDNS as a free DNS provider option (#124)
DuckDNS's API is smaller than the other providers' (no list/read API,
no arbitrary sub-records, one A/AAAA + one TXT record per domain), so
domains are entered by the operator instead of auto-discovered, and
getRecords reads from public DNS since there's nothing else to query.
Documented as a free option in the README and DNS provider docs.
2026-07-13 23:41:09 -04:00
wmantlyandClaude Sonnet 5 5139fbb79a Documentation cleanup for public release (#123)
Prepares the docs for the public release announcement: removes obsolete/dead
material, fixes drift between the API reference and the actual routes, and
standardizes on the default GitHub Pages URL.

- Remove Vagrant entirely: delete Vagrantfile, docs/dev_setup.md, and stale
  vagrant references in .gitignore/.dockerignore; rewrite openresty/README.md
  to describe the actual (currently unused) directory and point to
  ops/nginx_conf/ for the real OpenResty config.
- Delete docs/Update 4.11.md (personal scratch changelog) and drop both its
  and dev_setup.md's references from docs/README.md's Legacy Documentation
  section.
- Remove checkmark emoji from docs/contributing.md's PR Requirements list.
- Bring the auth model docs up to date with the code: document
  GET /api/auth/oidc/start + /callback, the /api/permission and /api/group
  RBAC routers, the /api/dns/dynamic/* sub-API, and /api/api-token (self
  -service PATs) in both nodejs/api.md and docs/api.md; add the missing
  "Clear Host Cache" section; drop the invite-token/SSH-key endpoints that no
  longer exist in nodejs/routes/user.js; note admin-only routes. Mention
  OIDC/LDAP/RBAC as core features in README.md.
- Keep nodejs/api.md and docs/api.md fully in sync (same body, differing only
  in Jekyll front matter / relative links) instead of letting them drift.
- Fix Node.js version references (20.x -> 22.x) in README.md and
  docs/installation.md to match ops/install.sh and the Dockerfile.
- Note that the manual nginx-conf/systemd install steps in README.md and
  docs/installation.md won't auto-track repo changes the way install.sh's
  symlink approach does, and recommend install.sh.
- Update the stale test/unit file lists in docs/contributing.md and
  nodejs/test/README.md to match the actual directory contents.
- Add npm run test:integration to README.md's Running Tests section.
- Add nodejs/conf/, nodejs/controller/, and nodejs/migrations/ to the project
  structure diagrams in README.md, docs/architecture.md, and
  docs/contributing.md.
- Standardize "CloudFlare" -> "Cloudflare" everywhere to match the actual API
  value in nodejs/models/dns_provider.js.
- Add the missing app_auth__adminGroups row to DEPLOYMENT.md's app_* table.
- Delete docs/CNAME (custom domain) so GitHub Pages serves from the default
  https://theta42.github.io/proxy/, matching docs/README.md.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:20:59 -04:00
wmantlyandClaude 3df7d8c5cb install.sh: OpenResty repo distro/component fix for Debian (#122)
Follow-up to #121: the repo line still used the live codename + "main", but
the openresty.org Debian tree only publishes up to bookworm (no trixie) and
uses the "openresty" component, not "main". Verified against the repo:
  /package/debian/dists/  -> bookworm bullseye buster jessie stretch (no trixie)
  bookworm Release        -> Components: openresty
  /package/ubuntu/dists/  -> noble jammy focal ... ; Components: main

So:
- Debian: distro = host codename when published (jessie..bookworm), else
  bookworm (binary-compatible with trixie, same OpenSSL 3 era); component
  "openresty".
- Ubuntu/Mint: distro = host codename; component "main" (unchanged).

Produces the working line on a trixie host:
  deb [...] http://openresty.org/package/debian bookworm openresty

docs/installation.md manual steps updated to match.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 20:41:51 -04:00
wmantlyandClaude 6158a3a693 install.sh: support Debian 13 (trixie) OpenResty repo (#121)
Two issues on Debian 13 (Trixie):

1. apt's sequoia GPG backend now rejects SHA-1 signatures, and the OpenResty
   repo signing key is still SHA-1 — so `apt-get update` fails to verify the
   repo. When /usr/share/apt/default-sequoia.config is present (Debian 13+),
   install a back-end override that extends the SHA-1 acceptance window to
   2028 (the OpenResty key is expected to rotate to a stronger algorithm;
   revisit before then). No-op on older Debian/Ubuntu. Idempotent on re-run.

2. The repo path was hardcoded to /package/ubuntu with the host codename,
   which worked on older Debian by coincidence. trixie lives under
   /package/debian, so pick the tree by distro ID (debian -> /package/debian,
   else -> /package/ubuntu).

docs/installation.md: mirror both changes in the manual install steps, with a
note that install.sh applies the sequoia override automatically.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 18:12:44 -04:00
wmantlyandClaude 8dcecbcfa2 Fix api-tokens date display + quiet authIO no-token log (#120)
- api_tokens.ejs: created_on/last_used_on come back from Redis as strings
  (model-redis only coerces fields with an explicit `type`), so `new Date(ms)`
  yielded "Invalid date". Use `moment(ms, "x")` (the hosts.ejs/dns.ejs
  precedent) which parses a numeric string-or-number as a Unix-ms timestamp.
- api_tokens.ejs: `isExpired` is a class getter not serialized to the client
  JSON, so the "expired" badge never showed — compute expiry in the view via
  `Date.now() > Number(expires_at)`. Also guard the `last_used_on: 0` / falsy
  case (string "0" is truthy) so unset timestamps render "—" not "1970".
- middleware/auth.js: authIO did `checkToken(socket.handshake.auth.token || 0)`,
  so any socket connect without a token (login page, pre-login) did an
  `AuthToken.get(0)` lookup and logged a noisy `EntryNotFound` trace. Guard:
  reject the socket with a generic 401 when there's no token (behavior-
  preserving — unauth sockets were already rejected; just no Redis lookup / 404).
- dns_provider.js: drop a stray `console.log('currentDomains:', ...)` debug
  line in updateDomains() (unrelated, noticed while investigating).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 17:41:57 -04:00
wmantlyandClaude a9a48c3445 Add self-service API tokens (PATs) with UI + Bearer auth (#119)
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>
2026-07-12 17:12:38 -04:00
wmantlyandClaude 8e78604a37 Persist Redis (AOF+vol), read config from mounted secrets.js, add backup/restore docs (#118)
Lossless upgrades + config story for the all-in-one proxy image.

Redis persistence (Part A):
- Replace in-memory `--save "" --appendonly no` with AOF + RDB persisted to
  /data. Host records, permissions, DNS creds, local users, AND the
  lua-resty-auto-ssl Let's Encrypt certs now all survive container recreation
  (persisting Redis persists the cert store — no LE re-issue / rate-limit on
  rebuild).
- Add the `proxy-data` named volume -> /data in docker-compose.yml; fix the
  stale "in-memory, lost on recreation" comment.

Config from ./config/proxy-secrets.js (Part B):
- docker-entrypoint.sh: when /config/proxy-secrets.js is mounted, symlink it to
  /app/conf/secrets.js so @simpleworkjs/conf reads the oidc/ldap/auth config
  from the file. No app_* env should then be passed (app_* beats secrets.js).
  Falls back to app_* env when the file is absent (standalone still works).
- docker-compose.yml: drop all app_oidc__* / app_ldap__* / app_auth__* env and
  add `./config:/config:ro`. Keep RESOLVER/REAL_IP_FROM/NODE_ENV/NODE_PORT
  (OpenResty-runtime / process env, not app_* config). No env_file.
- New secrets.js.example (the proxy had none): oidc (enabled, endpoints,
  clientId/clientSecret, redirectUri, scopes, claims), ldap (url, bindDN,
  bindPassword, searchBase, userFilter, tlsOptions), auth (adminGroups,
  adminUsers, groupRoleMap), plus an orchestrator-only `stack` key.

Backup/restore docs:
- Full "Backups and restore" runbook in DEPLOYMENT.md (what lives where, manual
  backup, Redis restore with the AOF-vs-RDB note — AOF wins on startup so the
  AOF must be deleted before an RDB load; restoring Redis restores cert state
  at snapshot time; migrations note). Update the Setup + Auto-SSL sections.
- docs/docker.md: update Quick start + How configuration works + Auto-SSL for
  the new ./config/ approach (app_* env now advanced/optional).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 13:00:34 -04:00
wmantly 3f178b038c Merge pull request #117 from theta42/docs-logs
docs: add a Logs (Docker) section
2026-07-11 21:06:05 -04:00
wmantlyandClaude 865fced691 docs: add a Logs (Docker) section
Document how to get logs when running the all-in-one image: docker compose
logs for app + OpenResty (stdout/stderr), and the nginx access/error logs
which go to /var/log/nginx on the proxy-logs volume (not docker logs).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 21:02:56 -04:00
wmantly cca5e483f9 Merge pull request #116 from theta42/feat/issues-48-57
Fix user creation & password (#48), per-host auth incl. SSO (#57), scheduler (#69) + host modal
2026-07-11 20:41:41 -04:00
wmantly 3ea9e0a9db Merge branch 'master' into feat/issues-48-57 2026-07-11 20:40:22 -04:00
wmantlyandClaude 94ad6143cc Dockerize the proxy (all-in-one image) + Docker docs
All-in-one Dockerfile bundling OpenResty + the Node mgmt app + Redis in one
container, mirroring the bare-metal ops/install.sh layout:
- Dockerfile (openresty/openresty:1.31.1.1-2-bookworm-fat base; dumb-init PID 1;
  luarocks install lua-resty-auto-ssl/luasocket/lua-resty-ipmatcher; node 22.x;
  npm ci --omit=dev; OpenResty confs + lua copied into place).
- docker-entrypoint.sh: fallback cert, sed-parameterize RESOLVER/REAL_IP_FROM,
  start bundled redis + node app, exec openresty foreground.
- docker-compose.yml (standalone), .dockerignore, DEPLOYMENT.md.
- nodejs/routes/render.js: /health endpoint for healthchecks.
- nodejs/models/user_ldap.js: tlsOptions forwarded to ldapts Client so the
  proxy can bind ldaps:// with a self-signed cert (app_ldap__tlsOptions__*).
- nodejs/package.json: bump @simpleworkjs/conf to ^1.1.0 (app_* env overrides).
- docs/docker.md + index.md: Docker deployment guide + fronting an SSO Manager.
- ops/proxy.service: add WorkingDirectory=/var/www/proxy/nodejs (bare-metal
  cwd fix so relative conf/ paths resolve).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 17:02:52 -04:00
wmantlyandClaude Opus 4.8 952c9434a4 Per-host SSO: OpenResty gate + auth location (#57)
- hostfeatures.lua: replace basic-auth-only enforcement with a combined
  apply_auth() that allows if EITHER basic auth OR a valid SSO session passes.
  A "Basic" Authorization header takes the basic path (401 on failure);
  otherwise a browser is 302'd to /__proxy_auth/start. SSO sessions are read
  straight from Redis (proxy_SsoSession_<sid>, sid from the __proxy_sso cookie,
  character-restricted) and matched to the host.
- proxy.conf: add a /__proxy_auth/ location (outside the gate) that forwards to
  the nodejs app so the OIDC flow can run and set the cookie on every host.
- nginx.conf: add the proxy_auth_backend upstream (defaults to 127.0.0.1:3000).

Needs live verification on an OpenResty box (no Lua/nginx runtime in CI here).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:44:30 -04:00
wmantlyandClaude Opus 4.8 87c0d024d5 Per-host SSO: Node auth endpoints + Redis session (#57)
Adds the /__proxy_auth OIDC flow served on every proxied host:
- routes/host_auth.js: /start (PKCE+state, per-host redirect_uri), /callback
  (exchange, enforce the host allow-list via utils/host_sso.identityAllowed,
  mint session + set __proxy_sso cookie), /logout.
- models/sso_session.js: SsoSession (Redis-backed, TTL'd; read directly by the
  Lua gate) and HostSsoState (in-flight auth request).
- utils/oidc.js: per-host redirect_uri override on buildAuthUrl/exchangeCode.
- conf.hostSso (reuses conf.oidc). Allow-list logic unit-tested.

Enforcement (Lua gate + nginx location) lands next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:41:28 -04:00
wmantlyandClaude Opus 4.8 c98214cc76 Host modal: Authentication tab, wildcard-child default, allow-list autocomplete
- 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>
2026-07-11 12:24:29 -04:00
wmantlyandClaude Opus 4.8 774d8e815e Host form: redesign as a tabbed modal
Replace the dense always-open add panel + inline edit card with a single
Bootstrap modal (shared by Add and Edit) organized into tabs: General, TLS &
Wildcard, Traffic, Headers, Access (IP + basic auth + SSO). Adds per-field
explanations, a full-width proxy list, and an "Add host" button. Preserves all
field names/ids, the challenge-type detection JS, and formAJAX wiring; drops the
form-clone edit mechanism in favor of populating the one modal. Verified in a
browser (add + edit, tab navigation, field population).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:10:54 -04:00
wmantlyandClaude Opus 4.8 653c79f099 Per-host SSO: data model + normalization (#57)
Add Host fields sso_enabled / sso_allow_users / sso_allow_groups (empty
allow-lists = any authenticated user) and normalize them (parseAllowList). SSO
reuses conf.oidc and is OR'd with basic auth. Enforcement (session cookie + Lua
+ nginx auth location) lands separately. Unit tests included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:01:54 -04:00
wmantlyandClaude Opus 4.8 42a00dd3bf Remove unused invite and SSH-key user features
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>
2026-07-11 11:57:29 -04:00
wmantlyandClaude Opus 4.8 3e5590288a Per-host HTTP basic auth (#57)
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>
2026-07-11 11:47:08 -04:00
wmantlyandClaude Opus 4.8 d1586b4d5a Fix user creation and password policy (#48)
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>
2026-07-11 11:38:09 -04:00
wmantlyandClaude Opus 4.8 6465e3d9f5 Scheduler: refresh DNS provider domain lists on interval (#69)
The host scheduler already checked wildcard cert expiry; add the second half of
the scheduler controller — DnsProvider.refreshAllDomains() re-syncs every
provider's domain list (get -> updateDomains, per-provider errors isolated),
scheduled 30s after start and every 24h alongside the cert check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:31:03 -04:00
wmantly c19cd2243e Merge pull request #115 from theta42/feat/permissions
Feat/permissions
2026-07-11 11:22:14 -04:00
wmantlyandClaude Opus 4.8 6d8dc45209 Validate host/target fields (hostname or IP; host allows */** wildcards)
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>
2026-07-11 11:18:47 -04:00
wmantlyandClaude Opus 4.8 2acc3644c4 Permissions: rename Grants, add wildcards, local groups, profile
- 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>
2026-07-11 10:54:15 -04:00
wmantly 098ff5bc9b Merge pull request #114 from theta42/feat/dynamic-dns
Dynamic DNS: keep A records pointed at the current public (WAN) IP
2026-07-11 00:37:24 -04:00
wmantlyandClaude Opus 4.8 83e9753b18 Dynamic DNS UI: fix blank columns, redesign the record list
The Host and Last-updated columns were blank because they relied on a jq-repeat
parseData hook that the rest of the app doesn't actually use for display (working
rows derive dates from the .momentFromNow class, not parseData).

- Derive fqdn server-side via DynamicRecord.toJSON so it flows through both the
  REST list and websocket payloads; the template uses {{fqdn}} directly.
- Render last-updated with the .momentFromNow class (data-date) like the rest of
  the app instead of a parseData-computed string.
- apply() now clears last_status on success so the UI only surfaces real errors.
- Redesign the section: prominent public-IP badge, cleaner add form, and a
  Bootstrap list-group of records (fqdn, IP badge, "updated N ago", inline error)
  with outline refresh/remove buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:32:21 -04:00
wmantlyandClaude Opus 4.8 a2d194f855 Add dynamic DNS: keep A records pointed at the current public IP
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>
2026-07-10 23:55:56 -04:00
wmantly 1f7a9e5ded Merge pull request #113 from theta42/fix/porkbun-domain-sync-zoneid
API issue
2026-07-10 23:42:35 -04:00
wmantly 762b46a5fa Merge branch 'master' into fix/porkbun-domain-sync-zoneid 2026-07-10 23:39:17 -04:00
wmantly 259f1350a3 API issue 2026-07-10 23:36:51 -04:00
wmantly 1638702bcd Merge pull request #112 from theta42/fix/porkbun-domain-sync-zoneid
Fix Porkbun/DigitalOcean domain sync (undefined zoneId aborts create)
2026-07-10 23:30:17 -04:00
wmantly f2bcce07f2 Merge branch 'master' into fix/porkbun-domain-sync-zoneid 2026-07-10 23:28:42 -04:00
wmantly 04a736a2b5 Merge pull request #111 from theta42/feat/per-host-proxy-controls
Per-host reverse-proxy controls: rate limiting, caching, headers, IP allow/deny
2026-07-10 23:28:18 -04:00
wmantlyandClaude Opus 4.8 39b4837fc6 Fix DigitalOcean domain sync (never set .domain)
DigitalOcean.listDomains ran the API response through __parseRes, which does
not map anything to `.domain` and additionally rewrites each item's `.name` to
its subdomain via tld-extract. DO's /v2/domains returns objects keyed `name`
with no zone id, so the domain objects had no usable `.domain` and Domain.create
threw ObjectValidateError ("domain is not string type"), aborting the sync.

Map name -> domain the way CloudFlare.listDomains does and skip __parseRes (it
is for record responses). DO has no zone id; the earlier zoneId-omit fix covers
that. Also request per_page=200 so accounts with >20 domains aren't truncated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:17:43 -04:00
wmantlyandClaude Opus 4.8 618b87fc10 Fix Porkbun (and other zoneless providers) domain sync
updateDomains passed `zoneId: domain.zoneId` unconditionally. Providers with
no zone concept (Porkbun, DigitalOcean) return domains without a zoneId, so
this sent an explicit `undefined`, which model-redis' processKeys rejects
("zoneId is not string type") and aborts the whole sync with a 422. Cloudflare
was unaffected because its domains carry a real zoneId string.

DnsProvider.create's catch only re-threw UnauthorizedDnsApi and swallowed
everything else, returning undefined — so the route then crashed on
`item.id` with an opaque "Cannot read properties of undefined" instead of the
real validation error.

- Omit zoneId from the Domain payload when the provider doesn't supply one.
- Re-throw non-Unauthorized errors from create so failures surface properly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:03:38 -04:00
wmantlyandClaude Opus 4.8 286b50b09b Raise default response-cache TTL to 48h
Bump proxy_cache_valid from 10m to 48h and match proxy_cache_path
`inactive` (must be >= the TTL or entries evict before expiring).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:19:25 -04:00
wmantlyandClaude Opus 4.8 6092468901 Add per-host reverse-proxy controls (rate limit, cache, headers, IP ACL)
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>
2026-07-10 22:10:55 -04:00
wmantly e8f1ca56ec Merge pull request #110 from theta42/ops/install-idempotent-symlinks
Make install.sh idempotent; symlink config from the repo
2026-07-10 13:27:44 -04:00
wmantly b11bf24a9e Merge branch 'master' into ops/install-idempotent-symlinks 2026-07-10 13:27:29 -04:00
wmantly 3ecf69ec93 Merge pull request #109 from theta42/fix/targetinfo-http2-coalescing
Fix HTTP/2 coalescing cross-routing (hassio served from metrics)
2026-07-10 13:27:12 -04:00
wmantly 1d0ebcbdc0 Merge branch 'master' into fix/targetinfo-http2-coalescing 2026-07-10 13:27:01 -04:00
wmantly f9d23323c1 Merge pull request #108 from theta42/fix/jquery4-removed-apis
Fix jQuery 4 removed-API crash in app-base.js
2026-07-10 13:26:43 -04:00
wmantly d7c7940835 Merge branch 'master' into fix/jquery4-removed-apis 2026-07-10 13:26:34 -04:00
wmantly cabd1c78ca Merge pull request #107 from theta42/feat/oidc-authz
OIDC login + per-domain authorization
2026-07-10 13:26:21 -04:00
wmantlyandClaude Opus 4.8 0cca3730fb Make install.sh CI/CD-friendly: deterministic force-sync to remote
The installer is meant to be run by CI/CD with no human writes on prod, so
updates should mirror the repo exactly rather than refuse on local drift:

- Replace `git pull --ff-only` with fetch + `checkout -B origin/$BRANCH` +
  `reset --hard` + `clean -fd` so the box always matches origin/$BRANCH.
- Set GIT_TERMINAL_PROMPT=0 so a missing/expired credential fails fast in CI
  instead of hanging on a prompt.
- npm ci --omit=dev (lockfile, production-only) with a plain-install fallback.
- Allow REPO_URL / REPO_DIR / BRANCH to be overridden from the environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:23:31 -04:00
wmantlyandClaude Opus 4.8 cff816fa06 Address CodeQL findings on the OIDC auth flow
- 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>
2026-07-10 13:22:12 -04:00
wmantlyandClaude Opus 4.8 ec168411bf Make ops/install.sh idempotent and symlink config from the repo
Rework the installer so it doubles as an updater:

- Idempotent throughout: `install -d` for dirs, apt source lists rewritten
  in place, `gpg --dearmor --yes`, fallback cert generated only if missing,
  repo cloned or fast-forwarded, and `ln -sfn` symlinks.
- Config is now symlinked straight from the checked-out repo instead of
  wget-ing raw files from GitHub. /etc/openresty/{nginx.conf,autossl.conf,
  sites-enabled/000-proxy}, the targetinfo.lua lualib, and the systemd unit
  all point at $REPO_DIR/ops, so an update is just `git pull` + reload with
  no re-copying. This also drops the external t42-common raw-file
  dependency (autossl.conf / proxy.conf now come from this repo).
- Validate `openresty -t` before reloading so a bad config can't take the
  proxy down; reload if running else restart.
- Fix prior bugs: stray `curl sudo apt-get update`, duplicate openssl cert
  line, and `cd ../nodejs` (now cd $REPO_DIR/nodejs). Require root; add a
  BRANCH override (default master).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:14:48 -04:00
wmantlyandClaude Opus 4.8 cd0cc50a19 Fix HTTP/2 coalescing cross-routing in targetinfo.lua
Hosts that share one wildcard cert (e.g. hassio.718it.biz and
metrics.718it.biz under *.718it.biz) resolve to the same IP, so browsers
coalesce them onto a single HTTP/2 connection. The SSL request_domain
phase resolves the connection's first host and caches it in
ngx.ctx.targetInfo; the unguarded `if targetInfo then return targetInfo`
then handed that first host's target to every coalesced request on the
connection -- e.g. hassio.718it.biz served from metrics' 192.168.1.8:8088.

Confirmed via debug logging: for GET requests to hassio.718it.biz on a
coalesced connection, ngx.ctx already held metrics' IP and the function
short-circuited to it.

Guard the reuse by host: only return the cached target when
ngx.ctx.targetInfo_domain matches the requested domain, and record that
domain whenever a target is resolved. A coalesced request for a different
host now re-resolves against its actual Host header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:05:43 -04:00
wmantlyandClaude Opus 4.8 08bef1bd00 Fix jQuery 4 removed-API usage in app-base.js
jQuery 4 removed $.isFunction and $.holdReady. Replace the isFunction
checks with typeof and drop the holdReady calls (the redirect already
guards the page). Mirrors the master hotfix (#108) so this branch is
testable on jQuery 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:24:01 -04:00
wmantlyandClaude Opus 4.8 f4ba086fd2 Fix jQuery 4 removed-API usage in app-base.js
jQuery 4 removed $.isFunction and $.holdReady, which threw at runtime
("$.isFunction is not a function" on the Clear Cache / delete path).

- Replace the three $.isFunction(callback) checks with typeof === 'function'.
- Drop the two $.holdReady calls in forceLogin; the redirect already keeps
  an unauthenticated user off the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:22:02 -04:00
wmantlyandClaude Opus 4.8 10abd36340 Add OIDC login and per-domain authorization
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>
2026-07-10 12:17:05 -04:00
wmantly 9f175f5bf6 Merge pull request #106 from theta42/deps/audit-fix
Update dependencies, clear all npm audit vulnerabilities
2026-07-10 11:14:01 -04:00
wmantly 58ba837cbd Merge branch 'master' into deps/audit-fix 2026-07-10 11:13:51 -04:00
wmantly cdca9fbd40 Merge pull request #105 from theta42/model-redis
TTL-based expiry for wildcard subdomain cache
2026-07-10 11:08:26 -04:00
wmantlyandClaude Opus 4.8 7ee8202a11 Update dependencies and clear all npm audit vulnerabilities
`npm audit fix` resolved all 12 advisories (7 high, 5 moderate) by
bumping transitive deps (axios/form-data/node-forge/path-to-regexp/qs/
ws/socket.io-parser/brace-expansion/follow-redirects/picomatch) within
the existing semver ranges. Audit is now clean (0 vulnerabilities).

Also refreshed direct deps:
- @fortawesome/fontawesome-free 7.1 -> 7.3 (minor)
- ldapts 8.1.2 -> 8.1.8 (patch)
- ejs 3.1 -> 6.0 (major)
- jquery 3.7 -> 4.0 (major)

All views render under ejs 6 (modern <%- include() %> syntax, no legacy
filters) and no jQuery-4-removed APIs are used in the views or public JS.
Unit + integration tests pass (55).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:58:38 -04:00
wmantlyandClaude Opus 4.8 7cc7f95019 Add TTL-based expiry for wildcard subdomain cache entries
Adopt model-redis v1.5.0 and give the on-demand is_cache Host records
(and their Cached tracking records) created by Host.addCache a TTL, so
they auto-expire instead of accumulating forever. Only the record hash
carries the TTL, so OpenResty's direct HGETALL sees a miss once it
expires and re-resolves through the lookup path.

The lifetime is configurable via conf.cacheTTL (seconds, default 3600;
0 disables expiry). This also mitigates the matchAny=false "wrong host"
bug: stale leftover cache entries now expire (and are still busted on
parent update), so undefined subdomains stop being served by old caches.

Add test/unit/wildcard_matchany.test.js covering the matchAny routing
decision (defined vs undefined subdomains, mixed-policy sibling
wildcards, and cache-entry behavior) and wire it into the test scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:51:06 -04:00
wmantly bb9bbb1d6e Merge pull request #104 from theta42/model-redis
Fixed clearcahce
2026-07-10 00:39:53 -04:00
wmantly 626a4189fa Fixed clearcahce 2026-07-10 00:39:25 -04:00
wmantly 1eda4a7cf5 Merge pull request #103 from theta42/model-redis
Fixed issue with lookup
2026-07-10 00:36:36 -04:00
wmantly 99cdbe6586 Fixed issue with lookup 2026-07-10 00:35:50 -04:00
wmantly c15e7d93ff Merge pull request #102 from theta42/model-redis
Fixed issue with matching wild card
2026-07-10 00:26:15 -04:00
wmantly 1295d4f5ce Fixed issue with matching wild card 2026-07-10 00:24:16 -04:00
wmantly e457cadf8a Merge pull request #101 from theta42/model-redis
Fixed issue with matching wild card
2026-07-10 00:17:20 -04:00
wmantly 56c2fb1a5c Fixed issue with matching wild card 2026-07-10 00:13:40 -04:00
wmantly 655aeb47a2 Merge pull request #100 from theta42/model-redis
Model redis
2026-07-09 22:20:53 -04:00
wmantly 398d2d9979 Merge branch 'master' of github.com:theta42/proxy into model-redis 2026-07-09 22:20:34 -04:00
wmantlyandClaude Opus 4.8 77674c6b1c Upgrade model-redis to ^1.4.0 and redis to ^6.1.0
model-redis 1.4.0 requires redis 6, so bump both together to keep a
single deduped redis version. Verified: unit tests pass (38), and the
real models load and run against live redis 7 on the new stack,
including model-redis 1.4.0's findOrphans().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:13:38 -04:00
wmantlyandClaude Opus 4.8 2c32ec0f3a Add wildcard_matchAny routing mode for wildcard hosts
A *.example.com wildcard host now chooses between two routing modes:
- wildcard_matchAny=false (default): only subdomains explicitly defined
  in redis route; undefined subdomains get no match (406)
- wildcard_matchAny=true: any subdomain catches-all to the wildcard
  parent host, preserving the previous behavior

The gate lives in the host_lookup socket service, which is only reached
for domains missing a direct redis entry, so defined children and
**-style hosts are unaffected. Adds the matching-mode selector to the
host add/edit form, shown for wildcard hosts.

Note: existing wildcard hosts have no wildcard_matchAny field and so
default to the stricter "only defined" mode until re-saved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:47:21 -04:00
wmantlyandClaude Opus 4.8 2665e7d466 Fix cert deletion key and harden nginx SSL/redis handling
- host.remove() now deletes the cert under the host key instead of the
  Domain relation object, so certs are actually removed from redis
- targetinfo.lua returns the redis connection to the pool via
  set_keepalive instead of leaking one connection per request
- autossl.conf drops TLSv1/1.1 and 3DES, adds TLSv1.3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:29:43 -04:00
wmantly 59bba8273a Fixed issue with cert renew 2026-02-25 22:59:04 -05:00
wmantly bcc777ccdf Delete certs from redis 2026-02-25 22:21:16 -05:00
wmantly 1bacc17a12 Fixed host put on frontend 2026-02-25 19:45:09 -05:00
wmantly 427e9411e7 Better model pub/sub errors 2026-02-25 19:30:54 -05:00
wmantly 585aa14c0b services 2026-02-25 19:17:25 -05:00
wmantly 74b8f1113e Prokbun API fix 2026-02-25 13:49:46 -05:00
wmantly 0a63e05028 Import path fix 2026-02-25 12:35:21 -05:00
wmantly b522e5064a Package updates 2026-02-25 12:16:38 -05:00
wmantly 21ff1b0bb5 DNS route 2026-02-25 12:11:31 -05:00
wmantly 5309719622 Create CNAME 2026-02-11 11:21:00 -05:00
wmantly e2cb6207eb Merge pull request #87 from theta42/model-redis
Moved to model-redis package
2025-12-31 20:45:58 -05:00