Rewire onto the shared @simpleworkjs/oidc-client, /ldap, and /app-stack
packages (deleting the byte-identical local forks of the same code), close the
LDAP filter-injection in User.get by routing the username through escapeFilter
(RFC 4515), align model-redis ^1.6.0 and ldapts ^8.1.8, and unify build_info to
{buildVersion, buildHash, buildYear}. package-lock regenerated from the npm
registry (no file:/link:), so npm ci is clean in docker builds.
Co-Authored-By: Claude <noreply@anthropic.com>
models/user_redis.js hardcoded the bootstrap anti-lockout admin
username to 'proxyadmin2', while migrations/permission_bootstrap.js
grants the global-admin permission to conf.auth.adminUsers[0]. An
operator who customized adminUsers away from the default ended up
with a bootstrapped account that had no admin permissions -- a
silent lockout. user_redis.js now derives the bootstrap username the
same way permission_bootstrap.js does.
Also corrected a secrets.js.example comment that claimed the
bootstrap password defaults to the username itself (it actually
generates and logs a random password), and refreshed all README
screenshots against the current UI, including a new load-balancing
screenshot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes#47.
- Added lua-resty-balancer to dependencies (Dockerfile & install.sh).
- Added 'targets' field to the Host model to hold additional targets.
- Updated the UI to allow inputting additional targets (IP:port).
- Updated targetinfo.lua to parse the additional targets and load balance between them and the primary target using resty.balancer.round_robin.
Host.lookUpWildcardParent() walked all labels of the host down to its own
leaf and only inspected that leaf's "*" child, so it found a wildcard
nested under the host (the base-domain case, e.g. *.cool.mysite.com for
cool.mysite.com) but missed the common case where the wildcard is a
SIBLING of the host's leftmost label (e.g. *.nl.wgnode.com covering an
already-existing sso.nl.wgnode.com). The /wildcard-parent route then
returned nothing and the edit form's "Parent Wildcard" option stayed
greyed out, leaving no way to convert an existing auto-SSL host onto a
wildcard issued afterward.
Track the parent node during the walk and check the sibling "*" slot too.
The never-created-subdomain case is unchanged (plain lookUp()'s wildcard
fallback in the route still handles it). Sync the test mock and add
regression coverage for the sibling case (existing host, never-created
host via the route fallback, and a deeper-wildcard negative case).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Fix MIT LICENSE copyright placeholder
- Remove private flag and correct GitHub repository URL in package.json
- Bump version to 1.1.16
- Genericize committed config defaults (example.com/localhost)
- Harden global error handler against information leakage
- Generate random initial password for proxyadmin2 bootstrap account
- Correct docs to describe CONF_SECRETS instead of symlink behavior
Co-Authored-By: Claude <noreply@anthropic.com>
- Plain hosts can now be renamed after creation (wildcard/child/cache hosts
stay locked, since other records reference them by name). Migrates the
cert cache key on rename.
- Along the way, found and fixed a real bug in the vendored model-redis
library: its rename path leaves a stray, incomplete hash behind under
the old key when an `always`-type field (updated_on) is defined earlier
in the schema than the primary key -- silently blocking that hostname
from ever being reused. Worked around at the Host model level (can't
patch node_modules).
- Host list now shows who created each host, and when.
- Host modal's tabs now scroll horizontally on narrow screens instead of
overflowing awkwardly.
- Added missing inline help text (Target SSL, wildcard matching behavior).
Bumps to v1.1.9.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
- Host.prototype.update() had no challengeType handling (only create() did),
so selecting "Parent Wildcard" on an existing host's edit form silently
did nothing. Added the same wildcard-parent lookup to update(), using a
new Host.lookUpWildcardParent() -- the existing lookUp() can't be reused
here since an already-created host resolves to its own leaf rather than
falling through to a sibling wildcard.
- A wildcard's issued cert covers both the base domain and *.base domain
(altNames), but the lookup tree stores the wildcard one level below its
base -- looking up the bare base domain landed on an empty parent node
and found nothing. buildLookUpObj() now also stamps that parent node,
order-independent (a real host explicitly created at that exact name
always still wins).
Verified both fixes against a real Redis-backed Host model (not just the
mocked lookup-tree tests) -- see PR description.
Bumps to v1.1.8.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
Air-gap:
- DynamicRecord.refreshAll() called getPublicIp() (api.ipify.org,
icanhazip.com, ifconfig.me) every 4h on a timer regardless of
whether any dynamic records were configured -- the one background
call in the repo not actually gated by feature use. Now skips the
lookup entirely when there's nothing to refresh.
- Removed the stray, unauthenticated GET /test page (a leftover
jq-repeat demo) that loaded jQuery + Mustache from external CDNs.
- Removed a dead IE<9-only html5shim script tag pointing at a domain
that no longer resolves.
Docs:
- New GET /docs (index) and /docs/:slug routes render this project's
own README, DEPLOYMENT, api.md, and docs/*.md server-side via
marked (new dependency) -- so the documentation is readable from
the running app with no route to GitHub Pages, where it otherwise
only lives. Public, no auth, same tier as the health endpoint.
- .dockerignore/Dockerfile updated: docs/, DEPLOYMENT.md, and
nodejs/api.md were previously excluded from the image entirely
("served via GitHub Pages, not from the image") -- now copied in
alongside README.md/tos.md-style, since they're needed at runtime.
listDomains() used to validate the token by calling DuckDNS's update
endpoint with ip/ipv6 omitted, which makes DuckDNS auto-detect and
apply this host's public IP -- so adding a provider instantly
repointed the domain. Validate via a fixed TXT marker instead, which
DuckDNS's API supports independently and doesn't touch routing.
- 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>
* 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).
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.
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.
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.
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.
- 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>
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>
Both were dead/incomplete: POST /api/user/key called a nonexistent
User.addSSHkey, and the invite flow (POST /api/user/invite, User.invite,
User.addByInvite, InviteToken) had no consumer or UI. Drop the routes, the
InviteToken model, and the per-backing invite methods.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds opt-in basic auth per Host, following the existing per-host controls
pattern:
- Host fields basicauth_enabled / basicauth_realm / basicauth_users
({user: base64(sha1(pw))}). Credentials are parsed to plaintext by the pure
host_features normalizer and hashed at the route layer (utils/basicauth.js),
so plaintext never reaches Redis.
- ops/nginx_conf/hostfeatures.lua enforces it in access phase: verifies the
Authorization header against base64(sha1(password)), fails closed with a 401
WWW-Authenticate challenge.
- hosts.ejs gains an enable toggle, realm, and a username:password textarea
(passwords never echoed back; blank keeps the current set).
Unit tests cover hashing (matches the htpasswd {SHA} vector), credential
parsing, and normalization. Note: the Lua path needs verification on a live
OpenResty box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Backend (utils/hostname_validate.js, enforced in routes/host.js on create/update):
- host: IPv4 or a wildcard pattern whose labels may be normal, "*" (one
fragment) or "**" (any depth, incl. a bare "**" catch-all) — matching
Host.lookUp. Lowered Host.host min length to 1 so "**"/"*" pass the model.
- target (ip): IPv4 or a strict hostname, no wildcards.
- Both reject scheme, "/", ":" and whitespace; 422 with per-field keys.
Frontend (val.js) mirrors the rules: host/target validators + hosts.ejs fields
point at them. Unit tests in test/unit/hostname_validate.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename Grant -> Permission end-to-end (model, routes, view, frontend,
bootstrap) and add an idempotent redis migration for existing records.
- utils/roles.js: glob domain matching (* = one label, ** = any depth) against
the full host; authz passes the full hostname.
- Local groups: LocalGroup model + admin routes/UI; membership merged into
Permission.effectiveFor so app groups behave like SSO groups.
- Subject autocomplete via GET /api/permission/subjects (users + derived groups).
- User profile page (/profile) and username in the navbar; /api/user/me now
returns merged/local/external groups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
- 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>