- 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>
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:
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>
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>
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>
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>