Commit Graph

331 Commits

Author SHA1 Message Date
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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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
wmantly 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