Commit Graph

88 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 f2bcce07f2 Merge branch 'master' into fix/porkbun-domain-sync-zoneid 2026-07-10 23:28:42 -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 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 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 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 626a4189fa Fixed clearcahce 2026-07-10 00:39:25 -04:00
wmantly 99cdbe6586 Fixed issue with lookup 2026-07-10 00:35:50 -04:00
wmantly 1295d4f5ce Fixed issue with matching wild card 2026-07-10 00:24:16 -04:00
wmantly 56c2fb1a5c Fixed issue with matching wild card 2026-07-10 00:13:40 -04:00
wmantly 2665e7d466 Fix cert deletion key and harden nginx SSL/redis handling
- host.remove() now deletes the cert under the host key instead of the
  Domain relation object, so certs are actually removed from redis
- targetinfo.lua returns the redis connection to the pool via
  set_keepalive instead of leaking one connection per request
- autossl.conf drops TLSv1/1.1 and 3DES, adds TLSv1.3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:29:43 -04:00
wmantly 59bba8273a Fixed issue with cert renew 2026-02-25 22:59:04 -05:00
wmantly bcc777ccdf Delete certs from redis 2026-02-25 22:21:16 -05:00
wmantly 1bacc17a12 Fixed host put on frontend 2026-02-25 19:45:09 -05:00
wmantly 585aa14c0b services 2026-02-25 19:17:25 -05:00
wmantly 74b8f1113e Prokbun API fix 2026-02-25 13:49:46 -05:00
wmantly 0a63e05028 Import path fix 2026-02-25 12:35:21 -05:00
wmantly 21ff1b0bb5 DNS route 2026-02-25 12:11:31 -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 3a31083871 Cleaned up file layout 2025-12-31 16:00:39 -05:00
wmantly ef3fffdef2 Updated DNS-01 validation 2025-12-31 16:00:21 -05:00
wmantly d944ca6957 Cleaned up file layout 2025-12-31 15:57:33 -05:00
wmantly 6e168b12b3 Fixed issue: Old domains not being removed on refresh 2024-08-15 22:10:15 -04:00
wmantly 17f8bec34d Polished off DNS api models 2024-08-15 18:51:36 -04:00
wmantly 24ff59a097 Merge branch 'dns' of github.com:theta42/proxy into dns 2024-08-13 15:18:53 -04:00
wmantly b27e1b1617 Added relations to redis ORM 2024-08-13 15:18:47 -04:00
noot d8c9621568 final 2024-08-13 19:16:00 +08:00
wmantly fd85371e4b Merge branch 'dns' of github.com:theta42/proxy into dns 2024-08-12 13:53:41 -04:00
wmantly 35d3a399e3 More UI stuff 2024-08-12 13:53:37 -04:00
noot c38d2c6d0c cf change wip 2024-08-13 01:29:57 +08:00
wmantly e86de04a69 Better UI for DNS 2024-08-11 22:57:18 -04:00
wmantly cb87f22d98 DNS API error message 2024-08-11 12:24:42 -04:00
wmantly 3e989992df Added digital ocean 2024-08-10 23:09:55 -04:00
wmantly 3df3341235 DNS stuff 2024-08-10 23:07:32 -04:00
wmantly f44284685f Fixed nginx conf issue 2024-08-07 21:34:03 -04:00