Commit Graph

46 Commits

Author SHA1 Message Date
wmantly 34b1413c96 Bump version to 1.1.6; update CHANGELOG 2026-07-16 19:02:59 -04:00
wmantly 7c0cb5eabd Bump version to 1.1.5; update CHANGELOG 2026-07-16 18:34:03 -04:00
wmantly 6cd3a5bc58 Update jq-repeat to 2.1.0; fix removed __setPut/__setTake API
jq-repeat 2.1.0 (release notes: https://github.com/wmantly/jq-repeat/releases/tag/v2.1.0)
brings real fixes (throttled-update race conditions, sorted-list
reverse() leaking elements, nested-scope isolation) and a few
behavior changes. Audited every usage in this repo against the
changelog before upgrading:

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

Real breakage found and fixed: users.ejs/groups.ejs/permissions.ejs
called $.scope.X.__setPut(fn)/__setTake(fn) as setter METHODS -- that
API is gone in 2.1.0. Insert/remove hooks are now set via direct
property assignment ($.scope.X.__put = fn), per the current README.
Verified live (real dev server + Playwright): before the fix, all
three pages threw "__setTake is not a function" and the
insert/remove row animations were broken; after, zero errors and the
hooks fire correctly.
2026-07-16 18:26:02 -04:00
wmantly 526545ee68 Bump version to 1.1.4; update CHANGELOG 2026-07-16 17:44:00 -04:00
wmantly 17e9ef2783 Bump version to 1.1.3; update CHANGELOG 2026-07-16 16:02:59 -04:00
wmantly 8565f4aa27 Bump version to 1.1.2 2026-07-16 15:36:50 -04:00
wmantly 7d9c63b049 Air-gap fixes + in-app /docs (README/DEPLOYMENT/api.md/docs/*)
Air-gap:
- DynamicRecord.refreshAll() called getPublicIp() (api.ipify.org,
  icanhazip.com, ifconfig.me) every 4h on a timer regardless of
  whether any dynamic records were configured -- the one background
  call in the repo not actually gated by feature use. Now skips the
  lookup entirely when there's nothing to refresh.
- Removed the stray, unauthenticated GET /test page (a leftover
  jq-repeat demo) that loaded jQuery + Mustache from external CDNs.
- Removed a dead IE<9-only html5shim script tag pointing at a domain
  that no longer resolves.

Docs:
- New GET /docs (index) and /docs/:slug routes render this project's
  own README, DEPLOYMENT, api.md, and docs/*.md server-side via
  marked (new dependency) -- so the documentation is readable from
  the running app with no route to GitHub Pages, where it otherwise
  only lives. Public, no auth, same tier as the health endpoint.
- .dockerignore/Dockerfile updated: docs/, DEPLOYMENT.md, and
  nodejs/api.md were previously excluded from the image entirely
  ("served via GitHub Pages, not from the image") -- now copied in
  alongside README.md/tos.md-style, since they're needed at runtime.
2026-07-16 15:26:10 -04:00
wmantly f98c4ec44f Bump version to 1.1.1 2026-07-16 13:56:57 -04:00
wmantly 22274eddbe Bump version to 1.1.0 2026-07-15 22:38:00 -04:00
wmantly a19ff81c76 Fix a worker-blocking Lua socket call and add gzip/caching for static assets
- ops/nginx_conf/targetinfo.lua's wildcard-subdomain lookup fallback used
  classic LuaSocket (require("socket.unix")) instead of an OpenResty
  cosocket. LuaSocket is blocking, and called from an nginx worker it
  stalls the ENTIRE worker — every other in-flight connection on it — for
  the round-trip to the Node app. Worse, the Node side never
  newline-terminated its response, so the old blocking receive() only ever
  returned via its read-timeout-then-partial-read fallback, meaning every
  single cache-miss lookup paid a fixed timeout penalty while blocking the
  whole worker. Replaced with an ngx.socket.tcp() cosocket (unix-domain via
  "unix:/path", the only cosocket API this lua-nginx-module ships) and
  newline-terminated the Node service's responses so receive() actually
  completes instead of timing out. Verified against a live container:
  previously this crashed OpenResty's Lua VM entirely
  (ngx.socket.unix doesn't exist); fixed version resolves fresh wildcard
  subdomains in ~2ms.
- Add gzip compression (`compression` middleware) and far-future
  Cache-Control on static assets (7d for vendor libs under
  /static-modules, 1h for the app's own /static JS/CSS, which isn't
  cache-busted). The admin UI is a traditional multi-page app that loads
  ~13 separate vendor/app JS+CSS files on every full navigation; previously
  none of them were compressed and Cache-Control was `max-age=0` (Express's
  default), forcing a revalidation round-trip for every asset on every page
  view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:41:31 -04:00
wmantly 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 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 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 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 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 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 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
wmantly 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 58ba837cbd Merge branch 'master' into deps/audit-fix 2026-07-10 11:13:51 -04:00
wmantly 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
wmantly 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 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
wmantly b522e5064a Package updates 2026-02-25 12:16:38 -05:00
wmantly 5913c5e8c3 Moved to model-redis package 2025-12-31 20:32:21 -05:00
wmantly 6cb2880131 Using the simpleworks conf now 2025-12-31 19:28:26 -05:00
wmantly 9c762e0e0c Updated versions 2025-12-31 19:08:56 -05:00
wmantly 6b8b08e94d Fix test suite for CI/CD compatibility
- Fix glob patterns in package.json test scripts (explicit file listing)
- Add error handling for chmod in unix_socket_json.js (test environments)
- Fix unhandled promise rejections in DNS provider contract tests
- Replace broken malformed JSON test with proper buffering test
- Add defensive file cleanup in unix socket tests

All 55 tests now pass successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 17:26:24 -05:00
wmantly c0cfed1b1d Updated docs 2025-12-31 16:55:14 -05:00
wmantly 5dd07edf1a Moved jq-repeat to use NPM package 2025-12-31 16:01:34 -05:00
dependabot[bot] 25ac1ed961 Bump axios from 1.7.2 to 1.7.4 in /nodejs
Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-16 03:31:09 +00:00
wmantly e86de04a69 Better UI for DNS 2024-08-11 22:57:18 -04:00
wmantly 3caa4087b7 Updated package versions 2024-08-07 15:00:29 -04:00
wmantly 84db245f4a fixed issue with redis models 2024-08-06 13:11:55 -04:00
wmantly 0eb6af1723 syntax cleanups 2024-07-31 20:19:14 -04:00
wmantly 441e372987 Added socket.io 2024-07-31 19:01:55 -04:00
wmantly fc66225cf5 new front end #33 2024-07-30 20:47:46 -04:00
wmantly 99ab768f45 fixed prefix issue 2023-08-17 13:26:47 -04:00
wmantly deca906ce1 vagrant update 2023-06-26 22:18:42 -04:00
wmantly 6e1b987c34 better wild cards 2020-12-21 00:14:12 -05:00
wmantly 607a5f33af ldap user 2020-05-01 17:57:25 -04:00
wmantly 255d6568fd new model model 2020-04-09 22:22:54 -04:00
wmantly e81fd45373 removed forever 2019-12-20 20:50:20 -05:00
wmantly 088c7207c1 updated package.json 2019-12-20 13:38:08 -05:00
wmantly 236753b383 moved all files to code directory 2019-09-02 11:26:48 -04:00