Compare commits

...
271 Commits
Author SHA1 Message Date
Jamie Curnow 66ab4b24d1 Stop non-admins injecting nginx directives through host fields
advanced_config is admin-only, but other fields are written into nginx
config as-is and the schema barely limited them. A non-admin could add
any directive through them:

- location path, forward_host and forward_path (any non-empty string)
- proxy host forward_host (a quote breaks out of set $server "...")
- redirection forward_domain_name and stream forwarding_host (their
  pattern allowed ";", e.g. "example.com; return 200; #")
- access list IPv6 addresses ("%.+" zone id allowed "::1%x; allow all")
- domain names (a newline got out of the header comment)

Validation
- New lib/nginx-values.js holds the rules. Values can't contain
  characters that end or change an nginx token: whitespace, control
  characters, ; { } " ' # \, and no $ in a host. Location paths allow a
  modifier and an unquoted or double-quoted URI, so =, ~, ~* and ^~
  locations and quoted regexes keep working.
- The API schemas use the same patterns, including the certificate
  schema's own copy of the domain rule. A test fails if they drift.
- generateConfig checks stored values again, so rows saved before these
  rules can't inject either. The check is about nginx structure only:
  a stored value the API would now reject, but which can't add
  directives, still renders. If a value is unsafe, nothing is written
  and the host is marked offline with the reason in nginx_err. The same
  check covers per-location access lists and the Let's Encrypt request
  config.
- htpasswd user names can't contain ":" or control characters, which
  corrupted the file.

Fixes along the way
- IPv6 client addresses: drop zone ids, which nginx's allow/deny
  rejects anyway, and fix broken escapes (literal "d" for digits, an
  unescaped ".").
- Rewrite the domain rule used by redirection and stream hosts so it
  can't backtrack exponentially. It accepts the same values.
- Pattern errors still say "must match pattern", but no longer quote
  the regex.

Tests
- backend/test/nginx-values.test.js, run by `yarn test` and in
  scripts/ci/test-and-build. Real request schemas reject an injection
  set and accept valid values on every endpoint. Accepted values,
  rendered through the real templates and parsed using nginx's
  tokenizer rules, keep the same statement structure. The check
  refuses unsafe stored rows.
- NginxValues.cy.js: injection attempts from a non-admin get 400, and
  hosts using valid edge-case values come up with nginx_online true.
2026-11-10 08:20:02 +10:00
Jamie Curnow 5b7e34eb67 Fix second round of review findings from PR #6029
Security
- Check advanced_config permissions before creating a certificate on
  host update (proxy, redirection and 404 hosts). A refused request
  previously requested a Let's Encrypt certificate first, leaving an
  orphan certificate and using up rate limit.
- Only trust X-NPM-Peer-Addr when it comes with a secret generated at
  container start. The admin nginx sends both headers. Any proxy host or
  stream forwarding to 127.0.0.1:3000 was also a loopback peer, so it
  could forge the header and get a fresh rate-limit bucket on every
  request. Without the secret (dev, or a mounted production.conf), the
  header is ignored.
- Add a login limit per IP across all accounts (30 failed attempts a
  minute) alongside the per-account one. Before this, one address could
  spray passwords over any number of accounts. Behind a shared proxy,
  everyone using that proxy shares this limit.
- Stop trusting X-Real-IP from loopback. It was only there for an older
  version of the rate limiter, and it let a stream host forwarding to a
  local proxy host forge the client IP and get past IP access lists.
  Loopback now behaves as it does on master.

Correctness
- Refuse to delete a certificate that hosts still use (400, with counts
  per host type). Their configs include its files, and a successful
  revoke deleted them anyway, which broke nginx -t. Otherwise the
  certificate is revoked and its files are removed even if the revoke
  fails.
- Let non-admins clear a location's advanced config. Deleting the
  location was already allowed and has the same effect, and a location
  deleted and re-added at the same path arrives with an empty config,
  which was refused.
- Clear proxy host grants (meta.proxy_host_ids) when visibility isn't
  "user" or proxy hosts are hidden, and drop ids of deleted hosts.
  Before, hidden grants came back when proxy hosts were shown again.
  The backend enforces this; the permissions modal also stops sending
  them.
- 404 host and redirection host modals now wait for the current user
  to load, so an admin's advanced config change isn't dropped if they
  submit before it loads.

Tests
- NonAdminHosts.cy.js: refused change doesn't create a certificate,
  location rename and clear rules, grant clearing.
- Certificates.cy.js: deleting a certificate that is in use.
2026-11-09 23:39:26 +10:00
Jamie Curnow 04c1f5ae76 Fix review findings from PR #6029
Security
- Redact certbot output before it reaches the API: strip the
  certificate's DNS credential values, URL query strings and userinfo,
  secret-looking key=value pairs and Bearer/Basic tokens. DNS plugins
  can echo request URLs carrying API keys. The raw CommandError is no
  longer attached as `previous`, since the error handler returns
  `previous` to the client on certificate routes.
- Rate limit login/2FA on the admin nginx's real TCP peer, passed as
  X-NPM-Peer-Addr ($realip_remote_addr), instead of X-Forwarded-For.
  nginx trusts X-Real-IP from private ranges, so a client on the LAN or
  Docker network could get a fresh bucket on every request. Behind
  another proxy, limits now apply per account through that proxy.
- Bound rate limiter memory: hash keys over 128 chars and cap each store
  at 50k entries, removing expired entries first and then the oldest.

Correctness
- Proxy host modal: send location advanced configs unchanged for
  non-admins instead of looking them up by path, which dropped the
  config from a renamed location.
- Location advanced config: locations that omit advanced_config now get
  a config only after explicit ones are matched, so a new location at
  an old path can't take the config from the renamed location.
- Certificate upload: check that the certificate matches the private
  key (uploaded or stored) before writing, so a mismatched pair can't
  break nginx -t.
- Certificate delete: keep the Let's Encrypt files when revoke fails and
  hosts still use the certificate, so their configs still load.

Cleanup
- Make the rate-limit certbot hint first in the list, as its comment
  says.
- Proxy host getCount uses the permissions access has already loaded
  instead of querying user_permission again.
- Rate limiter: register the success refund listener in one place and
  call it a fixed window, not a sliding one.
2026-11-09 22:53:53 +10:00
Jamie Curnow 0792a5e24f Linting fixes 2026-11-09 22:33:10 +10:00
Jamie Curnow 779927c995 Fix getHostsWithDomains eager loading unnecessarily 2026-11-09 22:28:50 +10:00
Jamie Curnow 87b77d9461 Optimise isAdmin lookups 2026-11-09 22:25:07 +10:00
Jamie Curnow 8342713d45 Fix a non-admin can now rename a location that has admin-written
advanced config, and still can't change or add advanced config
2026-11-09 22:21:40 +10:00
Jamie Curnow 4175cf66c9 Fix: deleting a certificate now returns success even when some of its files
can't be removed
2026-11-09 22:17:07 +10:00
Jamie Curnow af54d2e346 Fix nginx reloading too much when cert renewal for multiple certs 2026-11-09 22:15:17 +10:00
Jamie Curnow 9b1566aa6b Fix for the broad Cloudflare hint regex (bare numbers
6003/6111/9103/9109/10000, plus 'Authentication error' and 'Invalid
access token') is checked before the rate-limit, DNS and HTTP hints, so
non-Cloudflare failures get a misleading Cloudflare message.
2026-11-09 22:14:07 +10:00
Jamie Curnow fff2b0d097 Fix for changing the upstream Host from $host to $forward_host
($http_host) forwards the raw client Host header, including the port and
any absolute-form URI mismatch, to every upstream and into the asset
cache key
2026-11-09 22:09:34 +10:00
Jamie Curnow 99678969b3 Fix a proxy host whose access list can't be loaded is now denied to
everyone instead of being served with no restrictions
2026-11-09 22:04:32 +10:00
Jamie Curnow 0cde59a8ad Fix for rate limiter only trusts X-Forwarded-For when the socket is
loopback, and the admin nginx sets that header to its own $remote_addr
2026-11-09 22:00:26 +10:00
Jamie Curnow 36b3434b9c Fix for adding meta.certificate_key to omissions()
means upload() now reads a row with no private key, so an upload that
leaves out certificate_key wipes the stored key and writeCustomCert
writes undefined to privkey.pem.
2026-11-09 21:57:00 +10:00
jc21 71be654418 Merge branch 'master' into develop 2026-11-09 14:01:11 +10:00
Jamie Curnow 47c9bfba4f Update dependencies 2026-11-09 11:55:20 +10:00
jc21 7c767f43b0 Merge pull request #6028 from occultsound/zh-tw-locale
Add Traditional Chinese (Taiwan) translation
2026-11-09 07:42:44 +10:00
occultsound 15beff767d Add Traditional Chinese (Taiwan) translation 2026-11-09 02:16:59 +08:00
jc21 53ee436e35 Merge pull request #6026 from Stephan-P/patch-1
Update nl.json
2026-11-08 18:53:40 +10:00
Stephan Paternotteandgreptile-apps[bot] e824d353f0 Update frontend/src/locale/src/nl.json
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-11-07 15:55:09 +01:00
Stephan Paternotte 3f2c186771 Update nl.json
Lang: Dutch translation file updated + some minor corrections
2026-11-07 15:47:16 +01:00
jc21 ea7e7468df Merge pull request #6023 from Alexis-Loskoutoff/fix-authentication-oracle
Fix authentication oracle
2026-11-06 08:58:10 +10:00
Alexis Loskoutoff e93b771ff5 Check a dummy hash when a login names no account
Without a password to check, a login for an unknown account skipped bcrypt
and answered far sooner than one with a wrong password, which still told
which emails have an account.
2026-11-05 18:29:52 +01:00
Alexis Loskoutoff c08f2e65a8 Add test for the same error on every failed login 2026-11-05 17:29:48 +01:00
Alexis Loskoutoff 0d90542119 Give the same error for every failed login
An unknown or passwordless account was rejected without the i18n key that
a wrong password gets, so the response told which emails have an account.
2026-11-05 17:29:48 +01:00
jc21 2f5342b427 Merge pull request #5908 from auzcoder/fix/auth-rate-limit-and-permissions
feat: rate limit auth endpoints and scope user proxy host permissions
2026-11-05 23:05:08 +10:00
Abdulhafiz Davlatov bc8bbf3562 feat: rate limit auth endpoints and scope user proxy host permissions
- Add in-memory sliding window rate limiter for auth token endpoints with CI/test bypass and successful request exclusion
- Add user_permission meta column migration and Objection model configuration
- Support scoping allowed proxy hosts per user when in 'Created Items Only' visibility mode
- Restrict advanced nginx configuration editing on hosts to admin users only
- Fix user permissions route ID parsing and omit id from permission update payloads
- Add allowed proxy hosts selector in permissions modal and display permissions summary badges in users table
2026-11-05 12:16:36 +05:00
Jamie Curnow cc46ce5ab6 Biome schema match to package 2026-11-05 16:43:28 +10:00
Jamie Curnow b5ee4f7b1b Update pull request template 2026-11-05 10:34:33 +10:00
jc21 9be1cb7818 Merge pull request #5917 from nglinh28/feat/vietnamese-locale-update
i18n(vi): complete Vietnamese locale and add vi tests
2026-11-05 10:30:02 +10:00
jc21 fe58b4eba0 Merge pull request #5889 from StopDragon/i18n/ko-missing-keys
Update Korean translation
2026-11-05 10:25:43 +10:00
jc21 18fc758b4f Merge pull request #5934 from lennondotw/fix/preserve-host-port
fix(proxy): preserve client ports in forwarded Host headers
2026-11-05 10:15:05 +10:00
jc21 5795699091 Merge pull request #5939 from hafizahmedzia/fix/http-certificate-result-keys
fix: preserve hostname keys in HTTP certificate test results
2026-11-05 10:05:11 +10:00
jc21 b88ca8e2e5 Merge pull request #5948 from Waterman1997/patch-1
Fix nginx reload after certificate renewal
2026-11-05 10:04:02 +10:00
jc21 d504c2bf25 Merge pull request #5951 from Alexis-Loskoutoff/fix/update-infomaniak-certbot-plugin
Update infomaniak certbot plugin to 0.3.3
2026-11-05 10:03:35 +10:00
jc21 49662bdc91 Merge pull request #5952 from lsr0/certbot-hetzner-fix-4.0.0
Update Hetzner certbot plugin to v4.0.0
2026-11-05 10:02:57 +10:00
jc21 f23dd9bff0 Merge pull request #5969 from capitain-schos/feature/add-dns-provider-ipp
added ip-projects certbot definitions
2026-11-05 09:59:23 +10:00
jc21 2a8b39bdef Merge pull request #5971 from Juice-de-Orange/fix/hsts-subdomains-per-host
fix: apply the HSTS sub-domains setting per host
2026-11-05 09:58:02 +10:00
jc21 23e56e9581 Merge pull request #5972 from junkerderprovinz/fix/route53-propagation
Skip propagation seconds option for Route 53 DNS challenge
2026-11-05 09:56:43 +10:00
jc21 3d09072ea7 Merge pull request #5976 from l1n-x/actualize-beget-package
Changed target plugin for Beget
2026-11-05 09:36:45 +10:00
jc21 4daa54c12e Merge pull request #5977 from mirfalltnixein/fix/unsaved-modal-changes
Prevent data loss when dismissing unsaved forms
2026-11-05 08:58:53 +10:00
jc21 5457ca0965 Merge pull request #5979 from seaeagle1/develop
Add certbot plugin for Scaleway DNS
2026-11-05 08:57:44 +10:00
jc21 1dd0b8fbe8 Merge pull request #5989 from Rip-07/fix/5964-stale-nginx-pid
fix: remove stale nginx pid file before starting nginx
2026-11-05 08:45:23 +10:00
jc21 5db7516f40 Merge pull request #5992 from chenghaopeng/patch-1
Update package name and version for Baidu DNS plugin
2026-11-05 08:44:51 +10:00
jc21 bd34c94222 Merge pull request #5993 from SebZar/feat/certificate-details-tooltip
feat: show certificate details in host tables
2026-11-05 08:42:57 +10:00
Jamie Curnow 9754c4c639 Bump mid version, as Tabler has changed the dark UI it's worth more than a revision change 2026-11-05 08:38:55 +10:00
Jamie Curnow db292e947d Bump version 2026-11-05 08:00:40 +10:00
Jamie Curnow ec0f30106d Migrate cypress to v16 2026-11-05 07:32:24 +10:00
Jamie Curnow 1c76fb76ea install yarn in cypress image 2026-11-04 22:08:48 +10:00
Jamie Curnow f915ca2462 Update all deps, and cypress 2026-11-04 20:54:23 +10:00
jc21 55481adede Merge pull request #6002 from mikeyhenein/frontend-deps
Frontend: refresh dependencies, react-intl 12, vitest 5, happy-dom 21; fix Tabler 1.7 regressions
2026-11-04 20:36:18 +10:00
jc21 7e240e4136 Merge pull request #6010 from NginxProxyManager/dependabot/npm_and_yarn/frontend/decode-uri-component-0.5.1
build(deps): bump decode-uri-component from 0.5.0 to 0.5.1 in /frontend
2026-11-04 14:42:39 +10:00
dependabot[bot] 3fb2f7261f build(deps): bump decode-uri-component from 0.5.0 to 0.5.1 in /frontend
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.5.0 to 0.5.1.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.5.0...v0.5.1)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-version: 0.5.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-04 04:42:08 +00:00
jc21 bea51343ef Merge pull request #5998 from mikeyhenein/vitest-node25
Frontend tests: disable Node's built-in localStorage so vitest works on Node 25+
2026-11-04 14:41:54 +10:00
jc21 42fbab8058 Merge pull request #5999 from mikeyhenein/gitattributes-lf
Add .gitattributes to keep LF line endings
2026-11-04 14:41:31 +10:00
jc21 dca46af3fc Merge pull request #6000 from mikeyhenein/s6-overlay-3.2.3.2
s6-overlay 3.2.3.2
2026-11-04 14:41:12 +10:00
jc21 4aab1f50a4 Merge pull request #6001 from mikeyhenein/backend-deps
Backend: refresh yarn.lock within the declared ranges (fixes 15 of 16 advisories, incl. body-parser DoS)
2026-11-04 14:40:53 +10:00
jc21 44ada6ad5a Merge pull request #6003 from mikeyhenein/permissions-schema
Reject unknown keys in PUT /api/users/:id/permissions (an extra key crashes the backend)
2026-11-04 14:34:24 +10:00
jc21 e3631e88c6 Merge pull request #6004 from mikeyhenein/certbot-errors
Show certbot's failure reason in the UI instead of "Internal Error"
2026-11-04 14:33:39 +10:00
jc21 fdf51a654c Merge pull request #6005 from mikeyhenein/certificate-delete-files
Remove a certificate's certbot files on delete even when revoking fails
2026-11-04 14:30:08 +10:00
jc21 0dc9933409 Merge pull request #6008 from wikrie/patch-2
Update dns-multi plugin version to 5.6.0
2026-11-04 08:28:49 +10:00
wikrie 044d093ec5 Update dns-multi plugin version to 5.6.0
update needed as used version 4.9 did not exists anymore
2026-11-03 21:27:47 +01:00
mikeyheneinandClaude Opus 5.5 d7a79a547f Remove a certificate's certbot files on delete even when revoking fails
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:39 -08:00
mikeyheneinandClaude Opus 5.5 d131419907 Show certbot's failure reason in the UI instead of "Internal Error"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:38 -08:00
mikeyheneinandClaude Opus 5.5 c68456b205 Frontend: refresh dependencies, react-intl 12, vitest 5, happy-dom 21; fix Tabler 1.7 regressions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:37 -08:00
mikeyheneinandClaude Opus 5.5 5aed3a5539 Reject unknown keys in PUT /api/users/:id/permissions (an extra key crashes the backend)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:37 -08:00
mikeyheneinandClaude Opus 5.5 c214a49afb Backend: refresh yarn.lock within the declared ranges (fixes 15 of 16 advisories, incl. body-parser DoS)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:36 -08:00
mikeyheneinandClaude Opus 5.5 b28e0d5442 s6-overlay 3.2.3.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:35 -08:00
mikeyheneinandClaude Opus 5.5 2a27e542e9 Add .gitattributes to keep LF line endings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:33 -08:00
mikeyheneinandClaude Opus 5.5 66fd49ee46 Frontend tests: disable Node's built-in localStorage so vitest works on Node 25+
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:28:31 -08:00
zarges.sandClaude Opus 5.5 d0739b7210 fix: drop DNS provider from certificate popover
Host endpoints clear the certificate meta, so the DNS provider is never
available in the host tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:36:15 +01:00
zarges.sandClaude Opus 5.5 435ddeee34 feat: show certificate details in host tables
The SSL column of the proxy, redirection, 404 and stream host tables
only showed the certificate type, so it was not possible to tell which
certificate a host uses.

- Popover on the SSL column with name, ID, provider (incl. DNS
  provider), domain names and expiry date
- SSL label turns red when the certificate is expired and yellow when
  it expires within 30 days
- Custom certificates show their name below the label

Refs #5705

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:16:22 +01:00
鹏鹏 c19ce19f90 Update package name and version for Baidu DNS plugin
The previous PyPi package has stopped being updated and is unavailable, rewrite one
2026-11-02 15:44:09 +08:00
Askhat AkhtanovandClaude Opus 5.5 c23a1cb343 fix: remove stale nginx pid file before starting nginx
When /run/nginx is already owned by PUID:PGID, the ownership step skips
it, so a leftover nginx.pid owned by root stays in place and nginx,
running as PUID:PGID, fails with "Permission denied" on every start.

Nginx is not running yet during prepare, so remove the pid file there.

Fixes #5964

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:33:27 +05:00
seaeagle1 d2e8e5650a Add Scaleway DNS 2026-10-28 13:29:10 +01:00
mirfalltnixein 50d413f464 Protect partial location and auth drafts 2026-10-28 00:34:50 +01:00
mirfalltnixein 5e76f904ee Address modal safeguard review feedback 2026-10-28 00:30:52 +01:00
mirfalltnixein 6838868fdc Prevent dismissing unsaved form modals 2026-10-28 00:04:24 +01:00
Роман Саркисян 26e27e3503 updated beget target plugin 2026-10-28 02:09:01 +07:00
Junker der ProvinzandPascaru 9780e22792 Skip propagation seconds option for Route 53 DNS challenge
certbot deprecated --dns-route53-propagation-seconds in 2.5.0 and
removed it in 3.0.0, so requesting a Route 53 certificate with a
propagation value fails with "unrecognized arguments". The plugin
waits for the record through the AWS GetChange API instead.

Fixes #4702
Fixes #5414

Co-authored-by: Pascaru <23747773+pascaruchan@users.noreply.github.com>
2026-10-24 19:17:37 +02:00
Juice-de-Orange 076c522ccc fix: apply the HSTS sub-domains setting per host
_hsts_map.conf defined $hsts_header with the host's own value in every
host config. A map is global to nginx, so one definition won for all
hosts and the "HSTS Sub-domains" switch of the others was ignored.

Define two fixed maps instead and let _hsts.conf pick one per host.
They get new names, so configs generated before this change, which
still define $hsts_header, can't override them. The maps keep HSTS
limited to https.

Fixes #5947
2026-10-24 16:38:10 +02:00
capitain-schos 518dd04679 added ipp certbot definitions 2026-10-24 14:15:25 +02:00
Lindsay Roberts d9c4d33a7c fix(certbot): update Hetzner plugin to v4.0.0 2026-10-18 21:19:53 +03:00
Alexis Loskoutoff b6e9038f0c Update infomaniak plugin to 0.3.3 2026-10-18 19:42:35 +02:00
CnpPt a57ffb0ea9 Update certificate.js 2026-10-17 12:20:45 +08:00
CnpPt b7d1e99e8c Reload Nginx after renewing SSL certificate 2026-10-17 11:50:38 +08:00
lennondotw 3a455595e7 fix(proxy): isolate cached assets by forwarded authority 2026-10-14 14:48:22 +08:00
lennondotw c50c06530b Merge remote-tracking branch 'upstream/develop' into fix/preserve-host-port 2026-10-14 14:37:17 +08:00
jc21 1aca775cb9 Merge pull request #5944 from jeezat/develop
fix(certbot): update Akamai Edge DNS plugin and credentials for v0.3.0
2026-10-14 07:10:53 +10:00
jc21 556738fd4d Merge pull request #5946 from Alexis-Loskoutoff/require-authentication-to-validate-certificates
Require authentication to validate certificates
2026-10-14 07:09:56 +10:00
Alexis Loskoutoff f1bd157438 Add test for certificate validation with view-only permission 2026-10-13 19:32:53 +02:00
Alexis Loskoutoff 6acf346262 Add test for unauthenticated certificate validation 2026-10-13 18:35:50 +02:00
Alexis Loskoutoff 82529d6402 Require certificates:create permission to validate certificates 2026-10-13 18:34:20 +02:00
jeezat 8c5b4d8ffd fix(certbot): update Akamai Edge DNS plugin and credentials for v0.3.0 2026-10-13 07:57:28 +02:00
jc21 d780474583 Merge pull request #5938 from Alexis-Loskoutoff/Update-FR-translations
Update french translations
2026-10-12 15:12:57 +10:00
Hafiz Ahmed Zia 9972e78dc8 fix: preserve hostname keys in HTTP certificate test results
Preserve raw response keys for the HTTP certificate test endpoint so
hostname punctuation and distinct result entries survive normalization.
Add API and mounted-modal regression coverage for the exact candidate
reviewed against develop 56ab784117.

Related: NginxProxyManager/nginx-proxy-manager#5849

AI assistance: Codex assisted with the implementation, tests, independent
review and contribution text.
2026-10-12 08:36:32 +05:00
Alexis Loskoutoff 41713715ca Improve existing french translations 2026-10-11 22:55:39 +02:00
Alexis Loskoutoff 06b02f7731 Add missing FR translations 2026-10-11 21:53:42 +02:00
jc21 56ab784117 Merge pull request #5935 from NginxProxyManager/fix/5919-access-list-render
Fix access list rules and SSL missing from re-rendered host configs
2026-10-09 22:56:56 +10:00
Jamie Curnow 717877db3f Fix flakey cypress test 2026-10-09 22:35:02 +10:00
jc21 1454252acd Merge branch 'develop' into fix/5919-access-list-render 2026-10-09 07:32:34 +10:00
lennondotw 6b26999e34 fix(proxy): preserve client ports in forwarded Host headers
Keep the incoming Host authority for default and custom locations, with the existing hostname fallback when Host is absent. Add a built-image regression that validates Host against Origin and Referer and run it after the CI image build.
2026-10-08 15:50:24 +08:00
jc21 1028e139e4 Merge pull request #5930 from Juice-de-Orange/i18n/de-it-missing
Add missing German (de) and Italian (it) translations
2026-10-07 08:35:10 +10:00
jc21 7d7ed25715 Merge pull request #5929 from NginxProxyManager/dependabot/npm_and_yarn/backend/proxy-addr-2.0.8
build(deps): bump proxy-addr from 2.0.7 to 2.0.8 in /backend
2026-10-07 08:31:37 +10:00
Juice-de-Orange 3270873cc8 Add missing German (de) and Italian (it) translations 2026-10-06 20:42:18 +02:00
dependabot[bot] 81e7044a17 build(deps): bump proxy-addr from 2.0.7 to 2.0.8 in /backend
Bumps [proxy-addr](https://github.com/jshttp/proxy-addr) from 2.0.7 to 2.0.8.
- [Release notes](https://github.com/jshttp/proxy-addr/releases)
- [Changelog](https://github.com/jshttp/proxy-addr/blob/master/HISTORY.md)
- [Commits](https://github.com/jshttp/proxy-addr/compare/v2.0.7...v2.0.8)

---
updated-dependencies:
- dependency-name: proxy-addr
  dependency-version: 2.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 23:34:59 +00:00
jc21 3f1c0f27d3 Merge pull request #5925 from NginxProxyManager/dependabot/npm_and_yarn/backend/fast-uri-3.1.8
build(deps): bump fast-uri from 3.1.7 to 3.1.8 in /backend
2026-10-06 09:33:45 +10:00
Jamie CurnowandClaude Opus 5.5 2afd723cd8 Fix access list rules and SSL missing from re-rendered host configs
Some paths rendered host configs without fully loading the relations
the templates depend on, silently dropping access rules (and SSL):

- Enabling a proxy host loaded the access list without its clients or
  auth items, so the host came back with no access restrictions.
- Requesting a Let's Encrypt certificate re-rendered every host sharing
  its domains from bare rows, dropping access rules and SSL.

generateConfig now loads any referenced certificate or access list that
isn't fully loaded, so no caller can render a partial config. The two
callers above also fetch the relations themselves, and a warning is
logged when a host's access list is missing or has no rules.

Adds integration tests covering access lists across disable/enable.

Fixes #5919, #4286, #5710

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:13:31 +10:00
dependabot[bot] f5e6dfecfb build(deps): bump fast-uri from 3.1.7 to 3.1.8 in /backend
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.7 to 3.1.8.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-05 09:34:18 +00:00
jc21 778ab6b375 Merge pull request #5921 from NginxProxyManager/dependabot/npm_and_yarn/test/serialize-javascript-7.1.2
build(deps): bump serialize-javascript from 7.1.1 to 7.1.2 in /test
2026-10-05 19:32:57 +10:00
dependabot[bot] 092d212ad7 build(deps): bump serialize-javascript from 7.1.1 to 7.1.2 in /test
Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 7.1.1 to 7.1.2.
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v7.1.1...v7.1.2)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-version: 7.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-04 14:38:20 +00:00
nglinh28andClaude Sonnet 5 e7e69abb84 i18n(vi): complete Vietnamese locale and add vi tests
- Translate the keys added for the logs feature and access list
  inheritance so the Vietnamese UI has full key coverage
- Update Vietnamese help docs for access lists, certificates, hosts
  and streams
- Use a 24-hour clock for Vietnamese date and time formatting
- Add tests that check Vietnamese key coverage, ICU placeholders and
  that strings are translated

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 09:28:36 +07:00
jc21 f64d43b9ae Merge pull request #5913 from PrassanthVG/fix/ip-ranges-timer
Don't start IP ranges renewal timer when fetching is disabled
2026-10-02 08:48:48 +10:00
Prassanth 77dfc3d3c1 Don't start IP ranges renewal timer when fetching is disabled
IP_RANGES_FETCH_ENABLED=false only skipped the initial fetch; the
renewal timer still fetched the ranges every 6 hours.

Fixes #5748
2026-10-02 02:08:28 +05:30
jc21 2dfd691169 Merge pull request #5910 from NginxProxyManager/dependabot/npm_and_yarn/backend/ip-address-10.7.2
build(deps): bump ip-address from 10.5.0 to 10.7.2 in /backend
2026-09-30 07:01:24 +10:00
dependabot[bot] c699dc96d4 build(deps): bump ip-address from 10.5.0 to 10.7.2 in /backend
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.5.0 to 10.7.2.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.5.0...v10.7.2)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.7.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-29 07:19:54 +00:00
jc21 5dcaf28d7c Merge pull request #5891 from Amitind/fix/5128-hide-certificate-key
fix: omit certificate_key from certificate API responses
2026-09-26 19:30:31 +10:00
jc21 de65105744 Merge pull request #5886 from xJayMorex/fix-chown
fix: only chown /data/keys.json if necessary
2026-09-26 19:18:14 +10:00
Amit Yadav a54b73f153 fix: omit certificate_key from certificate API responses
The private key of custom certificates was returned in meta by GET /api/nginx/certificates and GET /api/nginx/certificates/{id}. Add meta.certificate_key to omissions(), the same way meta.dns_provider_credentials is hidden. The key stays in the database and is still written to /data/custom_ssl on upload.
2026-09-25 20:35:32 +05:30
JUNG JI YONG ccbffa76ff Update Korean translation
- Add missing strings (2FA, logs, per-location access lists, etc.)
- Fix translations that didn't match the UI context
- Minor wording fixes in Korean help docs
2026-09-25 23:19:18 +09:00
xJayMorex 4725153962 fix: only chown /data/keys.json if necessary 2026-09-25 11:49:17 +02:00
jc21 a29eae9dc2 Merge pull request #5880 from NginxProxyManager/develop
v2.16.0
2026-09-24 19:27:13 +10:00
Jamie Curnow 2cfd3395cf Adds integration tests for per path access lists, fixes ipv6 jsv,
and enforces host-wide access list when location access list is not set
2026-09-24 12:21:49 +10:00
jc21 c41ec008bb Merge pull request #5772 from drakhaw/develop
feat: per-path access lists, host logs modal
2026-09-24 08:38:14 +10:00
Dimas R. Wisnu 900bb1e480 fix: resolve merge conflicts breaking frontend CI
- LocationsFields: remove duplicated conflict block, integrate AccessField
  into the Row-based layout (fixes JSX parse error)
- ProxyHosts/Table: migrate to updated @tanstack/react-table API (useTable,
  createColumnHelper<Features, ProxyHost>)
- Add missing Estonian translations (action.logs, column.error)
- Re-sort en.json locale keys
2026-09-23 19:18:51 +07:00
Dimas Rakha Wisnu 633b653f79 Merge branch 'develop' into develop 2026-09-23 18:53:06 +07:00
jc21 58566c9d75 Merge branch 'master' into develop 2026-09-23 20:57:08 +10:00
jc21 c53f52ab51 Merge pull request #5878 from carlosalbertorg/feat/log-viewer
feat: Add a Logs viewer to the admin UI
2026-09-23 20:23:26 +10:00
carlosalbertorg 9445038b36 fix: Use forwarding_host instead of the renamed forward_ip column
The stream table's forward_ip column was renamed to forwarding_host
back in migration 20210423103500_stream_domain.js, so the stream log
source labels were always showing "undefined" for the forwarding
target. Spotted by @jc21 in review.
2026-09-23 07:00:19 -03:00
jc21 b1e0473a19 Merge pull request #5741 from addielaruee/fix/stream-ipv6-forward-host-brackets
fix(stream): bracket IPv6 forward host for valid nginx upstream
2026-09-23 14:29:27 +10:00
jc21 95f1b74757 Merge pull request #5862 from NginxProxyManager/dependabot/npm_and_yarn/frontend/dev-minor-updates-793a873af9
build(deps-dev): bump the dev-minor-updates group across 1 directory with 5 updates
2026-09-23 14:23:52 +10:00
jc21 8509e1b378 Merge pull request #5813 from vzagorovskiy/fix/regenerate-config-wrong-host-type
Use the row's own model and host type in regenerate-config
2026-09-23 14:11:52 +10:00
Jamie Curnow 5f35e4916f Bump version 2026-09-23 14:10:29 +10:00
jc21 da12ee5b0c Merge pull request #5814 from shawnhank/fix/dns-credentials-lifetime
Remove DNS provider credentials from disk after certbot runs
2026-09-23 14:08:29 +10:00
jc21 61af8e23d8 Merge pull request #5829 from NginxProxyManager/dependabot/npm_and_yarn/backend/qs-6.16.0
build(deps): bump qs from 6.15.3 to 6.16.0 in /backend
2026-09-23 14:06:28 +10:00
jc21 c4e421e679 Merge pull request #5830 from NginxProxyManager/dependabot/npm_and_yarn/test/humanfs/node-0.16.8
Bump @humanfs/node from 0.16.7 to 0.16.8 in /test
2026-09-23 14:06:13 +10:00
jc21 1e18bf9212 Merge pull request #5831 from cloudchristoph/fix/certbot-dns-azure-modern
fix(certbot): switch the Azure DNS plugin to certbot-dns-azure-modern
2026-09-23 14:06:00 +10:00
jc21 af1d4dd60f Merge pull request #5854 from hurole/patch-1
feat: add Tencent Cloud EdgeOne DNS provider
2026-09-23 13:59:16 +10:00
dependabot[bot] f47d85da9e build(deps-dev): bump the dev-minor-updates group across 1 directory with 5 updates
Bumps the dev-minor-updates group with 5 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.101.4` | `5.103.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.2.0` | `26.6.2` |
| [happy-dom](https://github.com/capricorn86/happy-dom) | `20.11.6` | `20.14.5` |
| [sass](https://github.com/sass/dart-sass) | `1.103.1` | `1.104.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` |



Updates `@tanstack/react-query-devtools` from 5.101.4 to 5.103.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.103.1/packages/react-query-devtools)

Updates `@types/node` from 26.2.0 to 26.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `happy-dom` from 20.11.6 to 20.14.5
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.11.6...v20.14.5)

Updates `sass` from 1.103.1 to 1.104.1
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.103.1...1.104.1)

Updates `vite` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.102.8
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: happy-dom
  dependency-version: 20.14.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: sass
  dependency-version: 1.104.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: vite
  dependency-version: 8.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 03:56:05 +00:00
jc21 ee33ecdfc2 Merge pull request #5874 from NginxProxyManager/dependabot/npm_and_yarn/frontend/prod-patch-updates-d144f49a8f
build(deps): bump the prod-patch-updates group across 1 directory with 2 updates
2026-09-23 13:52:39 +10:00
jc21 f48c3e583a Merge pull request #5876 from NginxProxyManager/dependabot/npm_and_yarn/frontend/dev-patch-updates-846ffa5b48
build(deps-dev): bump the dev-patch-updates group across 1 directory with 5 updates
2026-09-23 13:52:21 +10:00
jc21 a1713b79e8 Merge pull request #5877 from hulkbig/fix/authenticated-login-redirect-5753
fix(router): redirect authenticated login visits to dashboard
2026-09-23 13:51:32 +10:00
carlosalbertorg 4282d6c6e9 feat: Add a Logs viewer to the admin UI
Nginx Proxy Manager had no way to inspect application or nginx logs
from the web UI - admins had to shell into the container or read
`docker logs`. This adds an admin-only Logs page that can tail:

- the backend application log, now also mirrored to
  /data/logs/backend.log (in addition to stdout) and rotated by the
  logrotate timer that already runs every 2 days
- the Let's Encrypt/certbot log, which certbot already writes to
  /data/logs/letsencrypt.log via its existing --logs-dir flag
- per-host nginx access/error logs (proxy, redirection, 404 and
  stream hosts), with the file path always resolved server-side from
  a validated host_type enum + numeric host_id, never from client
  input

Reads use a reverse chunked scan (64KB chunks, capped at 5MB scanned
per request) instead of loading whole files into memory, and the
frontend polls every 5s only while the tab is focused and "Live" is
on, so this stays cheap on both CPU and memory. No new runtime
dependencies were added on either side.

Purely additive: two new admin-only endpoints
(GET /api/logs/sources, GET /api/logs/tail), no existing behaviour
changed.
2026-09-22 13:11:38 -03:00
BigHulk 50144205a0 fix(router): redirect authenticated login visits to dashboard 2026-09-22 18:22:57 +08:00
jc21 73f784ea95 Merge pull request #5875 from NginxProxyManager/dependabot/npm_and_yarn/backend/prod-minor-updates-b9f1cef99c
build(deps): bump moment from 2.30.1 to 2.31.0 in /backend in the prod-minor-updates group
2026-09-22 07:12:03 +10:00
dependabot[bot] c8fe9abe38 build(deps-dev): bump the dev-patch-updates group across 1 directory with 5 updates
Bumps the dev-patch-updates group with 5 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@formatjs/cli](https://github.com/formatjs/formatjs) | `6.16.19` | `6.16.30` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.26` | `8.5.28` |



Updates `@formatjs/cli` from 6.16.19 to 6.16.30
- [Release notes](https://github.com/formatjs/formatjs/releases)
- [Commits](https://github.com/formatjs/formatjs/compare/@formatjs/cli@6.16.19...@formatjs/cli@6.16.30)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `postcss` from 8.5.26 to 8.5.28
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.26...8.5.28)

---
updated-dependencies:
- dependency-name: "@formatjs/cli"
  dependency-version: 6.16.30
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: postcss
  dependency-version: 8.5.28
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 13:04:45 +00:00
dependabot[bot] 1cb352847a build(deps): bump moment in /backend in the prod-minor-updates group
Bumps the prod-minor-updates group in /backend with 1 update: [moment](https://github.com/moment/moment).


Updates `moment` from 2.30.1 to 2.31.0
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.30.1...2.31.0)

---
updated-dependencies:
- dependency-name: moment
  dependency-version: 2.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 13:04:26 +00:00
dependabot[bot] d11e0f6c51 build(deps): bump the prod-patch-updates group across 1 directory with 2 updates
Bumps the prod-patch-updates group with 2 updates in the /frontend directory: [query-string](https://github.com/sindresorhus/query-string) and [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom).


Updates `query-string` from 9.5.0 to 9.5.1
- [Release notes](https://github.com/sindresorhus/query-string/releases)
- [Commits](https://github.com/sindresorhus/query-string/compare/v9.5.0...v9.5.1)

Updates `react-router-dom` from 7.18.2 to 7.18.4
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.4/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.4/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: query-string
  dependency-version: 9.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-router-dom
  dependency-version: 7.18.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 13:04:22 +00:00
jc21 9ad799c13f Merge pull request #5869 from zalaghi/feat/persian-locale
Add Persian locale
2026-09-21 07:25:22 +10:00
jc21 5d0be3a5d5 Merge pull request #5871 from qwist1233-cpu/tr-missing
Add missing Turkish (tr) translations
2026-09-21 07:20:43 +10:00
qwist1233-cpuandClaude Fable 5.1 735149210e Add missing Turkish (tr) translations
Adds the 31 keys present in en.json but missing from tr.json (2FA, certificate key type, trust forwarded proto).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 11:05:01 +03:00
hr.deng dba90c36be style: fix indentation in dns-plugins.json 2026-09-20 15:19:13 +08:00
Amir Zalaghi 0e152e8afe Add Persian locale 2026-09-18 22:12:13 +02:00
dependabot[bot] f2b1711549 build(deps): bump qs from 6.15.3 to 6.16.0 in /backend
Bumps [qs](https://github.com/ljharb/qs) from 6.15.3 to 6.16.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.3...v6.16.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 21:38:16 +00:00
jc21 568c105945 Merge pull request #5858 from NginxProxyManager/dependabot/npm_and_yarn/backend/prod-patch-updates-14e248b7a3
Bump the prod-patch-updates group across 1 directory with 3 updates
2026-09-15 07:35:48 +10:00
jc21 3105a23834 Merge pull request #5861 from NginxProxyManager/dependabot/npm_and_yarn/frontend/prod-minor-updates-2b7ce5bc75
Bump the prod-minor-updates group across 1 directory with 6 updates
2026-09-15 07:35:21 +10:00
dependabot[bot] 803f4968a2 Bump the prod-minor-updates group across 1 directory with 6 updates
Bumps the prod-minor-updates group with 6 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [@tabler/core](https://github.com/tabler/tabler) | `1.4.0` | `1.5.1` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.4` | `5.102.8` |
| [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) | `9.1.2` | `9.2.4` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.8` | `19.3.0` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.8` | `19.3.0` |
| [react-intl](https://github.com/formatjs/formatjs) | `10.1.22` | `10.2.0` |



Updates `@tabler/core` from 1.4.0 to 1.5.1
- [Release notes](https://github.com/tabler/tabler/releases)
- [Changelog](https://github.com/tabler/tabler/blob/dev/docs/CHANGELOG.md)
- [Commits](https://github.com/tabler/tabler/compare/@tabler/core@1.4.0...@tabler/core@1.5.1)

Updates `@tanstack/react-query` from 5.101.4 to 5.102.8
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.102.8/packages/react-query)

Updates `@tanstack/react-table` from 9.1.2 to 9.2.4
- [Release notes](https://github.com/TanStack/table/releases)
- [Changelog](https://github.com/TanStack/table/blob/main/packages/react-table/CHANGELOG.md)
- [Commits](https://github.com/TanStack/table/commits/@tanstack/react-table@9.2.4/packages/react-table)

Updates `react` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react)

Updates `react-dom` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom)

Updates `react-intl` from 10.1.22 to 10.2.0
- [Release notes](https://github.com/formatjs/formatjs/releases)
- [Commits](https://github.com/formatjs/formatjs/compare/react-intl@10.1.22...react-intl@10.2.0)

---
updated-dependencies:
- dependency-name: "@tabler/core"
  dependency-version: 1.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.102.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@tanstack/react-table"
  dependency-version: 9.2.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-intl
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 13:04:46 +00:00
dependabot[bot] d8488048ca Bump the prod-patch-updates group across 1 directory with 3 updates
Bumps the prod-patch-updates group with 3 updates in the /backend directory: [@apidevtools/json-schema-ref-parser](https://github.com/APIDevTools/json-schema-ref-parser), [compression](https://github.com/expressjs/compression) and [mysql2](https://github.com/sidorares/node-mysql2).


Updates `@apidevtools/json-schema-ref-parser` from 16.0.0 to 16.0.2
- [Release notes](https://github.com/APIDevTools/json-schema-ref-parser/releases)
- [Commits](https://github.com/APIDevTools/json-schema-ref-parser/compare/v16.0.0...v16.0.2)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `mysql2` from 3.24.2 to 3.24.4
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.24.2...v3.24.4)

---
updated-dependencies:
- dependency-name: "@apidevtools/json-schema-ref-parser"
  dependency-version: 16.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: mysql2
  dependency-version: 3.24.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 13:04:01 +00:00
Crazy D 0596581ec9 feat: add Tencent Cloud EdgeOne DNS provider
### Summary

    This PR adds support for **Tencent Cloud EdgeOne (TEO)** as a DNS provider for Let's Encrypt DNS-01 certificate validation using the [`certbot-dns-
  edgeone`](<https://pypi.org/project/certbot-dns-edgeone/>) plugin.

### Details

    - **Provider Name:** Tencent Cloud EdgeOne
    - **Plugin Name:** `dns-edgeone`
    - **PyPI Package:** [`certbot-dns-edgeone`](https://pypi.org/project/certbot-dns-edgeone/) (v0.1.0+)
    - **Plugin Repository:** https://github.com/hurole/certbot-dns-edgeone
    - **License:** Apache-2.0

    ### Credentials Template

    ```ini
    dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID
    dns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY
    ```

### Verification

  • Verified certbot-dns-edgeone package installation and entrypoint discovery with Certbot.
  • Verified DNS-01 TXT record creation and cleanup flows via unit tests.
  • Formatted in backend/certbot/dns-plugins.json in alphabetical order.
2026-09-12 18:25:54 +08:00
jc21 58605cbc70 Merge pull request #5848 from siimaarmaa/develop
Translate the entire user interface into Estonian and ensure that the Estonian flag is displayed./Tõlgi kogu kasutajaliides eesti keelde ja kinnita Eesti lipp.
2026-09-09 10:11:03 +10:00
jc21 280fcde528 Merge pull request #5834 from 12LuA/patch-1
Remove broken sorting from Audit Logs
2026-09-09 07:27:36 +10:00
jc21 c70fcb9f7c Merge pull request #5836 from jmrplens/fix/invalidate-tokens-on-password-change
Invalidate tokens issued before a password change
2026-09-09 07:27:00 +10:00
Cursor AgentandSiim Aarmaa f477946bd9 Tõlgi kogu kasutajaliides eesti keelde ja kinnita Eesti lipp.
UI-tekstid olid eesti keele valikus, aga sisu oli inglise keeles. Nüüd on et.json täielikult eesti keeles (sh puudunud võtmed), abitekstid ühtlustatud ja lipukoodiks jääb EE (Eesti), mitte ET (Etioopia). Kuupäevad kasutavad et-EE lokaati ja 24-tunnist kellaaega.

Muudatuse tegi Aarmaa IT - Siim Aarmaa

Co-authored-by: Siim Aarmaa <siimaarmaa@users.noreply.github.com>
2026-09-08 15:52:39 +00:00
jc21 18f1fe22b1 Merge pull request #5837 from jamalkamaladdin/feat/az-locale
Add Azerbaijani locale
2026-09-08 07:04:06 +10:00
jc21 9fc56a2209 Merge pull request #5840 from NginxProxyManager/dependabot/npm_and_yarn/test/mocha-12.0.0
Bump mocha from 11.8.0 to 12.0.0 in /test
2026-09-08 07:03:37 +10:00
jc21 37b19d892c Merge pull request #5841 from NginxProxyManager/dependabot/npm_and_yarn/test/cypress-16.0.0
Bump cypress from 15.21.1 to 16.0.0 in /test
2026-09-08 07:03:25 +10:00
jc21 17f126482f Merge pull request #5838 from NginxProxyManager/dependabot/npm_and_yarn/backend/apidevtools/swagger-parser-13.0.0
Bump @apidevtools/swagger-parser from 12.1.0 to 13.0.0 in /backend
2026-09-08 07:03:09 +10:00
dependabot[bot] 81ec7e8d6a Bump cypress from 15.21.1 to 16.0.0 in /test
Bumps [cypress](https://github.com/cypress-io/cypress) from 15.21.1 to 16.0.0.
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/cypress-io/cypress/compare/v15.21.1...v16.0.0)

---
updated-dependencies:
- dependency-name: cypress
  dependency-version: 16.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 13:03:41 +00:00
dependabot[bot] dd45ad8f51 Bump mocha from 11.8.0 to 12.0.0 in /test
Bumps [mocha](https://github.com/mochajs/mocha) from 11.8.0 to 12.0.0.
- [Release notes](https://github.com/mochajs/mocha/releases)
- [Changelog](https://github.com/mochajs/mocha/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mochajs/mocha/compare/v11.8.0...v12.0.0)

---
updated-dependencies:
- dependency-name: mocha
  dependency-version: 12.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 13:03:33 +00:00
dependabot[bot] 33250a0e38 Bump @apidevtools/swagger-parser from 12.1.0 to 13.0.0 in /backend
Bumps [@apidevtools/swagger-parser](https://github.com/APIDevTools/swagger-parser) from 12.1.0 to 13.0.0.
- [Release notes](https://github.com/APIDevTools/swagger-parser/releases)
- [Changelog](https://github.com/APIDevTools/swagger-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/APIDevTools/swagger-parser/compare/v12.1.0...v13.0.0)

---
updated-dependencies:
- dependency-name: "@apidevtools/swagger-parser"
  dependency-version: 13.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 13:03:22 +00:00
Jamal 2070fd186a Add Azerbaijani locale 2026-09-07 11:58:07 +04:00
José M. Requena Plens e4585ac688 Stamp the password change from the app clock, not the database one
Your CI caught this: the check passed on SQLite and never fired on the
stack where the database container runs on a different timezone from the
app, so a stale token stayed valid. The comparison was between a token's
`iat`, which is UTC seconds from Node, and `auth.modified_on`, which the
driver hands back interpreted in the app's timezone. With the app on
Australia/Brisbane and the database on UTC, that column comes back ten
hours in the past and the token always looks newer than the change.

Record the moment in `auth.meta.password_changed_at` instead, written by
`setPassword` with the same `Date.now()` clock that mints `iat`. Same
unit on both sides, one clock, and no timestamp parsing: the Date and
local-string branch is gone, and so is the whole-second flooring that
Postgres microseconds made necessary.

Rows written before this have no marker and revoke nothing until their
next password change, which is the safe direction to be wrong in.
2026-09-06 20:15:12 +02:00
José M. Requena Plens 1ffe3609f4 Invalidate tokens issued before a password change
Tokens are stateless JWTs, so changing a password left every session that
the old one had opened working until its own expiry, up to a day later.
That is the case the password change is meant to close: an administrator
resetting a compromised account did not evict whoever was already in it.

The auth row already records when the password last changed, so no
migration is needed: `Access.init()` reads it alongside the user it
already loads and refuses a token whose `iat` is older. Both sides are
compared as whole seconds, which is all `iat` carries, so a token minted
in the same second as the change is kept. Postgres stores that column to
the microsecond, which is why the comparison is not done in milliseconds.

It is reported as 401 rather than the usual 403 because that is what the
frontend clears the session on, so the browser holding the dead token
lands on the login page instead of a page full of errors, and `can()`
lets that one error through unwrapped for the same reason.

Only the password does this. A user row changing (a rename, an avatar,
permissions) does not, and a user with no password auth row, which is
what a login through an external provider looks like, is not affected.
2026-09-06 19:48:02 +02:00
Luca 24b072a120 fix: Rename user.avatar to owner and disable sorting 2026-09-06 17:31:47 +02:00
Christoph Vollmann e482522792 fix(certbot): shorten the Azure credentials template
The template was the longest of all plugins (1181 characters, nine comment lines) and carried the upstream example secret that trips secret scanning. It now shows what a user has to fill in: the service principal, and one zone line in the format ZONE_NAME:RESOURCE_GROUP_ID, plus a link to the docs for everything else.
2026-09-04 22:55:19 +02:00
Christoph Vollmann 23b4e7d6f7 fix(certbot): switch the Azure DNS plugin to certbot-dns-azure-modern
certbot-dns-azure 2.6.1 requires certbot<4.0; installing it into the image's certbot 5.x venv downgrades certbot and acme to 3.3.0, and acme 3.3.0 no longer imports against pyOpenSSL 26 (#5606). The maintained fork certbot-dns-azure-modern keeps module, entry point, flags and credentials format, requires certbot>=3.0 without an upper bound and declares its own azure-mgmt-dns range, so the extra dependency pin is no longer needed.
2026-09-04 22:24:38 +02:00
jc21 a2d427902a Merge pull request #5828 from NginxProxyManager/dependabot/npm_and_yarn/backend/fast-uri-3.1.7
Bump fast-uri from 3.1.5 to 3.1.7 in /backend
2026-09-04 07:42:57 +10:00
dependabot[bot] 05b867c21d Bump @humanfs/node from 0.16.7 to 0.16.8 in /test
Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.7 to 0.16.8.
- [Release notes](https://github.com/humanwhocodes/humanfs/releases)
- [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node)

---
updated-dependencies:
- dependency-name: "@humanfs/node"
  dependency-version: 0.16.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 11:58:04 +00:00
dependabot[bot] ebbd262e06 Bump fast-uri from 3.1.5 to 3.1.7 in /backend
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 22:41:08 +00:00
jc21 5e6c53f0e2 Merge pull request #5726 from NginxProxyManager/dependabot/npm_and_yarn/test/axios-1.18.0
Bump axios from 1.19.0 to 1.20.0 in /test
2026-09-01 07:10:25 +10:00
jc21 baa5683bba Merge pull request #5823 from NginxProxyManager/dependabot/npm_and_yarn/backend/prod-minor-updates-1a71c66db8
Bump mysql2 from 3.23.4 to 3.24.2 in /backend in the prod-minor-updates group
2026-09-01 07:10:12 +10:00
jc21 386a166025 Merge pull request #5820 from NginxProxyManager/dependabot/npm_and_yarn/test/cypress/grep-7.0.0
Bump @cypress/grep from 6.0.3 to 7.0.0 in /test
2026-09-01 07:09:32 +10:00
dependabot[bot] f7a696cf08 Bump mysql2 in /backend in the prod-minor-updates group
Bumps the prod-minor-updates group in /backend with 1 update: [mysql2](https://github.com/sidorares/node-mysql2).


Updates `mysql2` from 3.23.4 to 3.24.2
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.23.4...v3.24.2)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.24.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 13:04:27 +00:00
dependabot[bot] ec65105db2 Bump @cypress/grep from 6.0.3 to 7.0.0 in /test
Bumps [@cypress/grep](https://github.com/cypress-io/cypress) from 6.0.3 to 7.0.0.
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/cypress-io/cypress/compare/@cypress/grep-v6.0.3...@cypress/grep-v7.0.0)

---
updated-dependencies:
- dependency-name: "@cypress/grep"
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 13:04:01 +00:00
dependabot[bot] aa211ea423 Bump axios from 1.19.0 to 1.20.0 in /test
Bumps [axios](https://github.com/axios/axios) from 1.19.0 to 1.20.0.
- [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.19.0...v1.20.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 10:43:00 +00:00
jc21 cd4448ebef Merge pull request #5713 from NginxProxyManager/dependabot/npm_and_yarn/test/systeminformation-5.31.17
Bump systeminformation from 5.31.6 to 5.31.17 in /test
2026-08-31 20:41:29 +10:00
jc21 e2546bfb51 Merge pull request #5737 from NginxProxyManager/dependabot/npm_and_yarn/frontend/immutable-5.1.9
Bump immutable from 5.1.5 to 5.1.9 in /frontend
2026-08-31 20:40:55 +10:00
jc21 c19182d978 Merge pull request #5743 from NginxProxyManager/dependabot/npm_and_yarn/test/tar-7.5.22
Bump tar from 7.5.15 to 7.5.22 in /test
2026-08-31 20:40:38 +10:00
jc21 1cbfe370ec Merge pull request #5756 from NginxProxyManager/dependabot/npm_and_yarn/docs/postcss-8.5.25
Bump postcss from 8.5.14 to 8.5.25 in /docs
2026-08-31 20:40:25 +10:00
jc21 6c2a57ad83 Merge pull request #5769 from NginxProxyManager/dependabot/npm_and_yarn/test/fast-uri-3.1.5
Bump fast-uri from 3.1.2 to 3.1.6 in /test
2026-08-31 20:40:10 +10:00
Shawn Hank 210366cca0 Stop recreating DNS credentials files on every backend restart
setupCertbotPlugins() wrote a credentials file for every DNS-01 certificate
each time the backend started, using flag "wx" so it only filled in missing
ones. That existed because the renew path did not write the file itself, so
something had to put it back before `certbot renew` looked for it.

With the previous commit the renew path writes the file immediately before
invoking certbot, so this is now the only thing putting those credentials back
on disk - and it does so for every certificate on every restart, which undoes
the cleanup entirely.

Removing the write leaves the `fs` import and the `promises` array unused. The
"Added Certbot plugins" log line is kept but now gates on plugins.length, since
it was previously gated on a promise array that only ever held credential
writes.
2026-08-29 00:11:15 -06:00
jc21 b75b0adaf2 Merge pull request #5812 from vzagorovskiy/fix/keep-failed-nginx-config-as-err
Keep the failed nginx config as a .conf.err file
2026-08-29 10:14:42 +10:00
Shawn Hank 918728fcac Remove DNS provider credentials from disk after certbot runs
The credentials file written for a DNS-01 challenge was only cleaned up when
certbot failed - the unlink sat in a catch block. On success the file stayed in
/etc/letsencrypt/credentials for the entire life of the certificate, holding a
live DNS provider API token in plaintext.

The file cannot simply be deleted at issuance, because certbot records its path
in the renewal config and reads it back on every `certbot renew`. So the renew
path now writes the file itself immediately before invoking certbot, and both
paths remove it in a finally block.

Net effect: the credentials exist on disk for the duration of a certbot run
rather than permanently. The value still lives in the certificates table, which
is unavoidable - it has to come from somewhere to be written at all.

renewLetsEncryptSslWithDnsChallenge reads the row directly from the model
because renew() sources its certificate from internalCertificate.get(), which
strips meta.dns_provider_credentials via omissions().
2026-08-28 17:40:21 -06:00
vzagorovskiy 0453ddca35 Use the row's own model and host type in regenerate-config
processItems() passed proxyHostModel and the literal "proxy_host" to
configure() for every host type. host_type selects both the template and
the output path, and each host type has its own id sequence, so
redirection hosts, 404 hosts and streams were rendered through
proxy_host.conf and written over /data/nginx/proxy_host/<id>.conf. The
proxy host sharing that id lost its config file and had the resulting
nginx error recorded in its own meta.
2026-08-28 12:02:52 +03:00
vzagorovskiy a570c0e503 Keep the failed nginx config as a .conf.err file
When `nginx -t` fails, configure() is meant to move the broken config to
<id>.conf.err so the failure can be inspected. renameConfigAsError()
unlinked the source file before renaming it, so the rename always failed
and the config was simply deleted. The deleteConfig() call after it then
removed any .err file left over from an earlier failure.

- unlink the destination .err file instead of the source
- return the rename promise so the delete does not race it
- pass delete_err_file = false so the new .err file survives
- drop the stale 4th argument in the success path, which silently made
  delete_err_file false and left old .err files behind
2026-08-28 11:47:02 +03:00
jc21 6383017b4c Merge pull request #5780 from NginxProxyManager/dependabot/npm_and_yarn/test/js-yaml-4.3.1
Bump js-yaml from 4.1.1 to 4.3.1 in /test
2026-08-28 16:29:35 +10:00
jc21 3f2cd26913 Merge pull request #5789 from quokkawiki/develop
Fixed Estonian flag bug
2026-08-28 14:16:55 +10:00
jc21 ece5dea0ad Merge branch 'develop' into develop 2026-08-28 07:57:25 +10:00
jc21 a75eb467ca Merge pull request #5807 from vgoer/changes-not-taking-effect
fix: Changes not taking effect
2026-08-28 07:51:55 +10:00
jc21 708ea5cfbb Merge pull request #5809 from tapacko88/feat/ukrainian-locale
Add Ukrainian locale
2026-08-28 07:50:51 +10:00
tapacko d77d5c643d Add Ukrainian locale 2026-08-26 23:39:25 +01:00
jc21 934a3fafe5 Merge pull request #5765 from fatihemre/feat/collapsible-custom-locations
Collapse custom locations into a filterable list
2026-08-27 07:42:43 +10:00
vgoer 08b4bbdbd4 fix: Changes not taking effect 2026-08-26 07:39:31 +00:00
jc21 a7677c18c3 Merge branch 'develop' into feat/collapsible-custom-locations 2026-08-26 13:00:05 +10:00
dependabot[bot] 1d894f8ed1 Bump fast-uri from 3.1.2 to 3.1.6 in /test
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.6.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.6)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 02:59:09 +00:00
dependabot[bot] 7cde02c446 Bump js-yaml from 4.1.1 to 4.3.1 in /test
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.3.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 02:59:07 +00:00
jc21 190b7a606d Merge pull request #5763 from NginxProxyManager/dependabot/github_actions/actions/stale-11
Bump actions/stale from 10 to 11
2026-08-26 12:57:43 +10:00
jc21 61e74cbb7f Merge pull request #5773 from zergflag/develop
Add LWS DNS provider
2026-08-26 12:57:00 +10:00
Jamie Curnow ba8117fbcf Update test deps 2026-08-26 12:56:32 +10:00
jc21 5e7fea7d56 Merge pull request #5787 from CallMeLeon167/develop
Add German translations for two-factor authentication and some UI elements
2026-08-26 12:54:23 +10:00
jc21 4f1d309a90 Merge pull request #5788 from setrin/fix/update-websupport-certbot-plugin
fix(certbot): update Websupport DNS plugin to 5.0.0
2026-08-26 12:52:52 +10:00
jc21 58ff491dc0 Merge pull request #5795 from xzy103/fix/tencentcloud-dns-apex-wildcard
Fix Tencent Cloud apex and wildcard certificate issuance
2026-08-26 12:52:00 +10:00
jc21 85ee5e2200 Merge pull request #5800 from frenck/fix/update-timeweb-certbot-plugin
fix(certbot): update Timeweb Cloud DNS plugin to 2.0.0
2026-08-26 12:50:39 +10:00
Jamie Curnow 09300941af Add white padding to qrcode, especially in dark mode 2026-08-26 12:48:36 +10:00
jc21 c4fa8daa87 Merge pull request #5801 from vgoer/qr-services-2FA
Switched to local QR code generation to prevent leaking 2FA keys to third-party QR services.
2026-08-26 12:47:56 +10:00
jc21 3d800c7138 Merge branch 'develop' into qr-services-2FA 2026-08-26 08:07:11 +10:00
Jamie Curnow 2ed0c47de8 Fix #5802 404 hosts disable missing async/await paradigm 2026-08-26 08:02:52 +10:00
vgoer 4d30946d2f fix: local QR code 2026-08-25 09:14:44 +00:00
vgoer a5e49a53c0 feat: Add react-qr-code dependency 2026-08-25 09:13:41 +00:00
Franck Nijhof 41539c027f fix(certbot): update Timeweb Cloud DNS plugin to 2.0.0 2026-08-25 10:15:38 +02:00
jc21 046d3e022f Merge pull request #5798 from vgoer/develop
Fix: Insufficient private key permissions led to a leak and  Effective fixing when setting PUID and PGid environment variables
2026-08-25 07:41:27 +10:00
vgoer c98b1685bd Merge pull request #1 from vgoer/private-key-permissions
Insufficient private key permissions led to a leak.
2026-08-24 17:49:40 +08:00
vgoer b7772016ba fix: Ensure JWT key file ownership matches PUID/PGID at startup 2026-08-24 09:30:36 +00:00
vgoer 29687987b2 fix: Restrict JWT key file permissions to 0600 2026-08-24 09:30:22 +00:00
jc21 ab893706da Merge pull request #5796 from timothestoifl24/claude/nifty-neumann-83e87c
Fix missing `new` on PermissionError so denied requests return 403, not 404
2026-08-23 00:11:02 +10:00
Timothé Stoifl 246c54503e Merge branch 'develop' into claude/nifty-neumann-83e87c 2026-08-22 15:40:15 +02:00
Jamie Curnow 4ecb047299 Update frontend packages
- Major update for react-table
- Fix Hosts menu staying around after selecting a menu item
2026-08-22 23:23:20 +10:00
Jamie Curnow 4e2b052b50 Updated backend packages 2026-08-22 23:10:26 +10:00
Timothé Stoifl ce4e35a3d4 Fix missing new on PermissionError in access.can()
The catch block in `access.can()` constructed `errs.PermissionError`
without `new`. The error constructors in `backend/lib/error.js` are plain
constructor functions that assign to `this` and return nothing, so calling
one without `new` evaluates to `undefined`.

The statement therefore did `throw undefined`, the express error handler
in `backend/app.js` received undefined (and could not read `.status` or
`.public` off it), and the request fell through to the catch-all 404
handler in `backend/routes/main.js`.

Net effect: every authorization failure raised by `access.can(...)` was
reported to clients as `404 Not Found` instead of `403 Forbidden`.

Line 45 of the same file already used `new` correctly, which shows this
was an oversight rather than deliberate resource-existence hiding. A grep
over `backend/` confirms this was the only error constructor invoked
without `new`.
2026-08-22 13:07:09 +02:00
Silas 4cbeecaad2 fix: bump certbot-dns-tencentcloud to 2.1.1 2026-08-22 16:19:50 +08:00
Daniel Jacob Butcher fc8b1eb1fb Fixed Estonian flag bug
The Estonian flag was appearing as an Ethiopian flag, due to a mixup of ISO 639 language codes and ISO 3166 country codes
2026-08-20 00:04:47 +03:00
Daniel Slavkovsky d64d9a8ab2 fix(certbot): update Websupport DNS plugin to 5.0.0 2026-08-18 11:13:20 +02:00
Leon Schmidt 70f00d60be Add German translations for two-factor authentication and various UI elements 2026-08-18 10:19:06 +02:00
zergflag f43ef16fb5 Update dns-plugins.json 2026-08-09 14:59:39 +02:00
Dimas R. Wisnu 35ad8227d7 fix: resolve biome lint errors
- Use optional chaining for nullable checks
- Replace template literals with string literals for plain SQL
- Add node: protocol to fs/promises import in setup.js
2026-08-07 17:01:20 +07:00
Dimas R. Wisnu 4a0f212289 feat: per-path access lists, host logs modal, PostgreSQL support
- Per-path access lists: assign different access lists to individual
  locations on the same proxy host
- Host logs modal: view access/error logs from proxy host dropdown
- PostgreSQL JSON containment query (@>) for location regeneration
- Locale keys: action.logs, column.error
2026-08-07 16:40:32 +07:00
Fatih Emre ab96d78f4d Collapse custom locations into a filterable list
The Custom Locations tab rendered every location fully expanded, which
becomes unusable once a host has more than a handful of them. Each row is
now a collapsible card showing its path and forward target, collapsed by
default, with the Add Location button moved to the top so it stays
reachable without scrolling to the end of the list.

A filter appears once there are five or more locations and matches on the
path as well as the forward scheme, host and port.

New locations are still appended to the end of the list, so the generated
nginx config is unchanged.

Also fixes two existing issues in this component: the per-location inputs
shared the same DOM ids across rows, and the advanced-config toggle state
was keyed by array index, so removing a location moved it onto the wrong
row.
2026-08-04 16:11:46 +03:00
dependabot[bot] a42614094f Bump actions/stale from 10 to 11
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 13:06:48 +00:00
dependabot[bot] 2cd346d054 Bump postcss from 8.5.14 to 8.5.25 in /docs
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.14 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.14...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-02 06:13:35 +00:00
dependabot[bot] 20b8068aaa Bump tar from 7.5.15 to 7.5.22 in /test
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.15 to 7.5.22.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.15...v7.5.22)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.22
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 16:21:19 +00:00
addielarue 9aa7fd5722 fix(stream): bracket IPv6 forward host for valid nginx upstream
A Stream Host with an IPv6 address as the Forward Host was accepted and
saved but never activated. The generated stream config rendered
`proxy_pass {{ forwarding_host }}:{{ forwarding_port }}` as e.g.
`fe80::528:3c87:e7bb:ab08:25`, which nginx rejects with
"invalid port in upstream" because an IPv6 literal must be wrapped in
square brackets before the port is appended (`[fe80::...]:25`).

Normalize the stream forward host in generateConfig (alongside the existing
per-host-type data massaging) using net.isIPv6(), so IPv6 hosts render as
`[address]:port` while IPv4 addresses and hostnames are emitted unchanged.
The mutation is applied to the deep-copied render object, so persisted and
audit data are unaffected.

Fixes #5740
2026-07-25 22:55:58 +10:00
dependabot[bot] 553f879c06 Bump immutable from 5.1.5 to 5.1.9 in /frontend
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.5 to 5.1.9.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.5...v5.1.9)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 08:31:47 +00:00
dependabot[bot] cbf2782571 Bump systeminformation from 5.31.6 to 5.31.17 in /test
Bumps [systeminformation](https://github.com/sebhildebrandt/systeminformation) from 5.31.6 to 5.31.17.
- [Release notes](https://github.com/sebhildebrandt/systeminformation/releases)
- [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.31.6...v5.31.17)

---
updated-dependencies:
- dependency-name: systeminformation
  dependency-version: 5.31.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 00:15:51 +00:00
Jamie Curnow a62c2a6dc3 Adds support for custom admin port via env var #3808
and documentation
2026-06-10 11:24:53 +10:00
jc21 a5db5ed156 Merge pull request #5498 from Yasha-ops/develop
fix: Changed order of escape to prevent RCE
2026-06-08 08:51:46 +10:00
jc21 101595b90b Merge pull request #5616 from NginxProxyManager/dependabot/npm_and_yarn/backend/node-rsa-2.0.0
Bump node-rsa from 1.1.1 to 2.0.0 in /backend
2026-06-08 08:36:58 +10:00
Yassine Damiri 45a3659ffd Change fs import to use fs/promises 2026-06-04 15:08:31 +02:00
Yassine Damiri a457fe4d96 Merge branch 'develop' into develop 2026-06-04 15:06:42 +02:00
jc21 33ef7c859e Merge pull request #5592 from NginxProxyManager/dependabot/npm_and_yarn/test/tmp-0.2.6
Bump tmp from 0.2.5 to 0.2.7 in /test
2026-06-04 14:22:00 +10:00
jc21 bfa2dca1b1 Merge pull request #5626 from NginxProxyManager/dependabot/npm_and_yarn/frontend/prod-minor-updates-d03944a3ba
Bump the prod-minor-updates group across 1 directory with 4 updates
2026-06-04 14:21:43 +10:00
jc21 32fc268edf Merge pull request #5622 from fo5for/update-s6
Update s6 overlay to 3.2.3.0
2026-06-04 07:29:55 +10:00
dependabot[bot] 300d0653e0 Bump the prod-minor-updates group across 1 directory with 4 updates
Bumps the prod-minor-updates group with 4 updates in the /frontend directory: [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query), [date-fns](https://github.com/date-fns/date-fns), [query-string](https://github.com/sindresorhus/query-string) and [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom).


Updates `@tanstack/react-query` from 5.100.14 to 5.101.0
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.0/packages/react-query)

Updates `date-fns` from 4.3.0 to 4.4.0
- [Release notes](https://github.com/date-fns/date-fns/releases)
- [Commits](https://github.com/date-fns/date-fns/compare/v4.3.0...v4.4.0)

Updates `query-string` from 9.3.1 to 9.4.0
- [Release notes](https://github.com/sindresorhus/query-string/releases)
- [Commits](https://github.com/sindresorhus/query-string/compare/v9.3.1...v9.4.0)

Updates `react-router-dom` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.16.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: date-fns
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: query-string
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-router-dom
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 21:22:00 +00:00
dependabot[bot] 06f060dc0f Bump tmp from 0.2.5 to 0.2.7 in /test
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.5 to 0.2.7.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.5...v0.2.7)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 21:21:13 +00:00
jc21 7e447acfcc Merge pull request #5617 from NginxProxyManager/dependabot/npm_and_yarn/frontend/prod-patch-updates-509dafd62b
Bump the prod-patch-updates group in /frontend with 3 updates
2026-06-04 07:19:19 +10:00
jc21 acd26d7adb Merge pull request #5612 from NginxProxyManager/dependabot/npm_and_yarn/test/prod-patch-updates-e5e2dd2d94
Bump the prod-patch-updates group in /test with 2 updates
2026-06-04 07:18:45 +10:00
jc21 1f5a56d0bd Merge pull request #5615 from NginxProxyManager/dependabot/npm_and_yarn/frontend/dev-patch-updates-6b7b087425
Bump the dev-patch-updates group in /frontend with 4 updates
2026-06-04 07:18:24 +10:00
jc21 258b522725 Merge branch 'develop' into dependabot/npm_and_yarn/backend/node-rsa-2.0.0 2026-06-04 07:17:53 +10:00
jc21 179c27e382 Merge pull request #5275 from 7heMech/develop
Add reuseport to improve performance and reduce packet drops
2026-06-04 07:16:50 +10:00
Tony Chau 94176d2f9c Update s6 overlay to 3.2.3.0 2026-06-03 20:03:29 +08:00
jc21 76f09db610 Merge pull request #5619 from NginxProxyManager/develop
v2.15.1
2026-06-03 14:14:30 +10:00
jc21 d51602b150 Merge branch 'master' into develop 2026-06-03 08:23:48 +10:00
Jamie Curnow af2de98c41 Bump version 2026-06-03 08:22:27 +10:00
dependabot[bot] 6311329772 Bump the prod-patch-updates group in /frontend with 3 updates
Bumps the prod-patch-updates group in /frontend with 3 updates: [react](https://github.com/facebook/react/tree/HEAD/packages/react), [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [react-intl](https://github.com/formatjs/formatjs).


Updates `react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react)

Updates `react-dom` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-dom)

Updates `react-intl` from 10.1.9 to 10.1.11
- [Release notes](https://github.com/formatjs/formatjs/releases)
- [Commits](https://github.com/formatjs/formatjs/compare/react-intl@10.1.9...react-intl@10.1.11)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-intl
  dependency-version: 10.1.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 14:00:10 +00:00
dependabot[bot] a08f821c54 Bump node-rsa from 1.1.1 to 2.0.0 in /backend
Bumps [node-rsa](https://github.com/rzcoder/node-rsa) from 1.1.1 to 2.0.0.
- [Release notes](https://github.com/rzcoder/node-rsa/releases)
- [Changelog](https://github.com/rzcoder/node-rsa/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rzcoder/node-rsa/compare/1.1.1...v2.0.0)

---
updated-dependencies:
- dependency-name: node-rsa
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 13:59:29 +00:00
dependabot[bot] fc4a409292 Bump the dev-patch-updates group in /frontend with 4 updates
Bumps the dev-patch-updates group in /frontend with 4 updates: [@formatjs/cli](https://github.com/formatjs/formatjs), [tmp](https://github.com/raszi/node-tmp), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@formatjs/cli` from 6.16.3 to 6.16.6
- [Release notes](https://github.com/formatjs/formatjs/releases)
- [Commits](https://github.com/formatjs/formatjs/compare/@formatjs/cli@6.16.3...@formatjs/intl-datetimeformat@6.16.6)

Updates `tmp` from 0.2.6 to 0.2.7
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.6...v0.2.7)

Updates `vite` from 8.0.14 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

Updates `vitest` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

---
updated-dependencies:
- dependency-name: "@formatjs/cli"
  dependency-version: 6.16.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tmp
  dependency-version: 0.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 13:58:55 +00:00
dependabot[bot] 62ed330053 Bump the prod-patch-updates group in /test with 2 updates
Bumps the prod-patch-updates group in /test with 2 updates: [@cypress/grep](https://github.com/cypress-io/cypress) and [eslint](https://github.com/eslint/eslint).


Updates `@cypress/grep` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/cypress-io/cypress/releases)
- [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/cypress-io/cypress/compare/@cypress/grep-v6.0.0...@cypress/grep-v6.0.1)

Updates `eslint` from 10.4.0 to 10.4.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.4.1)

---
updated-dependencies:
- dependency-name: "@cypress/grep"
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 13:57:48 +00:00
Jamie Curnow 0d832092a5 Only modify files in startup when they aren't mounted 2026-06-02 22:09:21 +10:00
Yassine Damiri 6629abed32 Use fs module for credential file operations
Refactor (from the review) credential file handling to use fs module for directory creation and file writing
2026-06-01 17:02:19 +02:00
Yassine DAMIRI 46bf3274d8 fix: Changed order of escape to prevent RCE 2026-04-20 01:00:43 +02:00
7heMech 18fc3fe770 Merge branch 'develop' into develop 2026-03-02 20:45:41 +02:00
7heMech e72dfc3bfd Add reuseport to improve performance and reduce packet drops 2026-02-04 12:14:10 +00:00
268 changed files with 15746 additions and 4862 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Force LF everywhere: these files run inside Linux containers
* text=auto eol=lf
*.png binary
*.jpg binary
*.ico binary
*.woff binary
*.woff2 binary
+12 -3
View File
@@ -1,6 +1,16 @@
<!-- WARNING: Any PR without a description will be closed. The title is not enough! --> <!--
<!-- ANOTHER WARNING: Don't go creating a duplicate PR! Check that someone hasn't already created something that tackles your fix and if so, help them first --> WARNINGS:
1. An AI bot will comment on this PR! If you don't get a perfect score or don't
address any issues raised, your PR won't get merged!
2. Any PR without a description will be closed. The title is not enough!
3. Don't go creating a duplicate PR! Check that someone hasn't already created
something that tackles your fix and if so, help them first
-->
## Why ## Why
@@ -28,4 +38,3 @@ you change will break API for existing users -->
- [ ] AI was used to write this - [ ] AI was used to write this
- [ ] AI was used to review this - [ ] AI was used to review this
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v10 - uses: actions/stale@v11
with: with:
stale-issue-label: 'stale' stale-issue-label: 'stale'
stale-pr-label: 'stale' stale-pr-label: 'stale'
+1
View File
@@ -9,4 +9,5 @@ test/node_modules
docker/dev/dnsrouter-config.json.tmp docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf docker/dev/resolv.conf
.claude .claude
test/__pycache__
+1 -1
View File
@@ -1 +1 @@
2.15.0 2.17.0
+1 -1
View File
@@ -1,7 +1,7 @@
<p align="center"> <p align="center">
<img src="https://nginxproxymanager.com/github.png"> <img src="https://nginxproxymanager.com/github.png">
<br><br> <br><br>
<img src="https://img.shields.io/badge/version-2.15.0-green.svg?style=for-the-badge"> <img src="https://img.shields.io/badge/version-2.17.0-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager"> <a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge"> <img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a> </a>
+2 -2
View File
@@ -7,8 +7,8 @@ Older versions are not actively maintained.
| Version | Supported | | Version | Supported |
| ------- | --------- | | ------- | --------- |
| 2.15.x (latest) | :white_check_mark: | | 2.16.x (latest) | :white_check_mark: |
| < 2.15.0 | :x: | | < 2.16.0 | :x: |
Docker images: `jc21/nginx-proxy-manager:latest`, `jc21/nginx-proxy-manager:2` Docker images: `jc21/nginx-proxy-manager:latest`, `jc21/nginx-proxy-manager:2`
+72 -89
View File
@@ -1,91 +1,74 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": [ "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
"**/*.ts", },
"**/*.tsx", "formatter": {
"**/*.js", "enabled": true,
"**/*.jsx", "indentStyle": "tab",
"!**/dist/**/*" "indentWidth": 4,
] "lineWidth": 120,
}, "formatWithErrors": true
"formatter": { },
"enabled": true, "assist": {
"indentStyle": "tab", "actions": {
"indentWidth": 4, "source": {
"lineWidth": 120, "organizeImports": {
"formatWithErrors": true "level": "on",
}, "options": {
"assist": { "groups": [
"actions": { ":BUN:",
"source": { ":NODE:",
"organizeImports": { ["npm:*", "npm:*/**"],
"level": "on", ":PACKAGE_WITH_PROTOCOL:",
"options": { ":URL:",
"groups": [ ":PACKAGE:",
":BUN:", ["/src/*", "/src/**"],
":NODE:", ["/**"],
[ ["#*", "#*/**"],
"npm:*", ":PATH:"
"npm:*/**" ]
], }
":PACKAGE_WITH_PROTOCOL:", }
":URL:", }
":PACKAGE:", }
[ },
"/src/*", "linter": {
"/src/**" "enabled": true,
], "rules": {
[ "preset": "recommended",
"/**" "correctness": {
], "useUniqueElementIds": "off"
[ },
"#*", "suspicious": {
"#*/**" "noExplicitAny": "off"
], },
":PATH:" "performance": {
] "noDelete": "off"
} },
} "nursery": "off",
} "a11y": {
} "useSemanticElements": "off",
}, "useValidAnchor": "off"
"linter": { },
"enabled": true, "style": {
"rules": { "noParameterAssign": "error",
"recommended": true, "useAsConstAssertion": "error",
"correctness": { "useDefaultParameterLast": "error",
"useUniqueElementIds": "off" "useEnumInitializers": "error",
}, "useSelfClosingElements": "error",
"suspicious": { "useSingleVarDeclarator": "error",
"noExplicitAny": "off" "noUnusedTemplateLiteral": "error",
}, "useNumberNamespace": "error",
"performance": { "noInferrableTypes": "error",
"noDelete": "off" "noUselessElse": "error"
}, }
"nursery": "off", }
"a11y": { }
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
} }
+53 -21
View File
@@ -32,28 +32,28 @@
"version": ">=0.1.0" "version": ">=0.1.0"
}, },
"azure": { "azure": {
"credentials": "# This plugin supported API authentication using either Service Principals or utilizing a Managed Identity assigned to the virtual machine.\n# Regardless which authentication method used, the identity will need the “DNS Zone Contributor” role assigned to it.\n# As multiple Azure DNS Zones in multiple resource groups can exist, the config file needs a mapping of zone to resource group ID. Multiple zones -> ID mappings can be listed by using the key dns_azure_zoneX where X is a unique number. At least 1 zone mapping is required.\n\n# Using a service principal (option 1)\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = E-xqXU83Y-jzTI6xe9fs2YC~mck3ZzUih9\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n\n# Using used assigned MSI (option 2)\n# dns_azure_msi_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\n\n# Using system assigned MSI (option 3)\n# dns_azure_msi_system_assigned = true\n\n# Zones (at least one always required)\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/dns1\ndns_azure_zone2 = example.org:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2", "credentials": "# Docs: https://cloudchristoph.github.io/certbot-dns-azure-modern/\n# Service principal with the \"DNS Zone Contributor\" role on the DNS zone\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = example-client-secret-not-real\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n# One line per DNS zone, format ZONE_NAME:RESOURCE_GROUP_ID\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/rg-dns",
"dependencies": "azure-mgmt-dns==8.2.0", "dependencies": "",
"full_plugin_name": "dns-azure", "full_plugin_name": "dns-azure",
"name": "Azure", "name": "Azure",
"package_name": "certbot-dns-azure", "package_name": "certbot-dns-azure-modern",
"version": "~=2.6.1" "version": "~=2.8.0"
}, },
"baidu": { "baidu": {
"credentials": "dns_baidu_access_key = 12345678\ndns_baidu_secret_key = 1234567890abcdef1234567890abcdef", "credentials": "dns_baidu_access_key = 12345678\ndns_baidu_secret_key = 1234567890abcdef1234567890abcdef",
"dependencies": "", "dependencies": "",
"full_plugin_name": "dns-baidu", "full_plugin_name": "dns-baidu",
"name": "baidu", "name": "baidu",
"package_name": "certbot-dns-baidu", "package_name": "certbot-dns-baidu-bcd",
"version": "~=0.1.1" "version": "~=2.0.0"
}, },
"beget": { "beget": {
"credentials": "# Beget API credentials used by Certbot\nbeget_plugin_username = username\nbeget_plugin_password = password", "credentials": "# Beget API credentials used by Certbot\n# Find on Settings -> Security -> BegetAPI\ndns_beget_plugin_username = username\ndns_beget_plugin_password = password",
"dependencies": "", "dependencies": "",
"full_plugin_name": "beget-plugin", "full_plugin_name": "dns-beget-plugin",
"name": "Beget", "name": "Beget",
"package_name": "certbot-beget-plugin", "package_name": "certbot-dns-beget-plugin",
"version": "~=1.0.0.dev9" "version": "~=1.0.0"
}, },
"bunny": { "bunny": {
"credentials": "# Bunny API token used by Certbot (see https://dash.bunny.net/account/settings)\ndns_bunny_api_key = xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx", "credentials": "# Bunny API token used by Certbot (see https://dash.bunny.net/account/settings)\ndns_bunny_api_key = xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
@@ -173,7 +173,7 @@
"full_plugin_name": "dns-multi", "full_plugin_name": "dns-multi",
"name": "DnsMulti", "name": "DnsMulti",
"package_name": "certbot-dns-multi", "package_name": "certbot-dns-multi",
"version": "~=4.9" "version": "~=5.6.0"
}, },
"dnspod": { "dnspod": {
"credentials": "dns_dnspod_email = \"email@example.com\"\ndns_dnspod_api_token = \"id,key\"", "credentials": "dns_dnspod_email = \"email@example.com\"\ndns_dnspod_api_token = \"id,key\"",
@@ -224,12 +224,20 @@
"version": "~=0.1.2" "version": "~=0.1.2"
}, },
"edgedns": { "edgedns": {
"credentials": "edgedns_client_secret = as3d1asd5d1a32sdfsdfs2d1asd5=\nedgedns_host = sdflskjdf-dfsdfsdf-sdfsdfsdf.luna.akamaiapis.net\nedgedns_access_token = kjdsi3-34rfsdfsdf-234234fsdfsdf\nedgedns_client_token = dkfjdf-342fsdfsd-23fsdfsdfsdf", "credentials": "client_token = YOUR_CLIENT_TOKEN\naccess_token = YOUR_ACCESS_TOKEN\nclient_secret = YOUR_CLIENT_SECRET\nhost = YOUR_HOST.luna.akamaiapis.net\n# Optional: account_key = YOUR_ACCOUNT_KEY",
"dependencies": "",
"full_plugin_name": "edgedns",
"name": "Akamai Edge DNS",
"package_name": "certbot-plugin-edgedns",
"version": "==0.3.0"
},
"edgeone": {
"credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY",
"dependencies": "", "dependencies": "",
"full_plugin_name": "edgedns", "full_plugin_name": "dns-edgeone",
"name": "Akamai Edge DNS", "name": "Tencent Cloud EdgeOne",
"package_name": "certbot-plugin-edgedns", "package_name": "certbot-dns-edgeone",
"version": "~=0.1.0" "version": ">=0.1.0"
}, },
"eurodns": { "eurodns": {
"credentials": "dns_eurodns_applicationId = myuser\ndns_eurodns_apiKey = mysecretpassword\ndns_eurodns_endpoint = https://rest-api.eurodns.com/dns-zones/", "credentials": "dns_eurodns_applicationId = myuser\ndns_eurodns_apiKey = mysecretpassword\ndns_eurodns_endpoint = https://rest-api.eurodns.com/dns-zones/",
@@ -325,7 +333,7 @@
"full_plugin_name": "dns-hetzner", "full_plugin_name": "dns-hetzner",
"name": "Hetzner", "name": "Hetzner",
"package_name": "certbot-dns-hetzner", "package_name": "certbot-dns-hetzner",
"version": "~=1.0.4" "version": "~=4.0.0"
}, },
"hetzner-cloud": { "hetzner-cloud": {
"credentials": "dns_hetzner_cloud_api_token = your_api_token_here", "credentials": "dns_hetzner_cloud_api_token = your_api_token_here",
@@ -373,7 +381,7 @@
"full_plugin_name": "dns-infomaniak", "full_plugin_name": "dns-infomaniak",
"name": "Infomaniak", "name": "Infomaniak",
"package_name": "certbot-dns-infomaniak", "package_name": "certbot-dns-infomaniak",
"version": "~=0.2.2" "version": "~=0.3.3"
}, },
"inwx": { "inwx": {
"credentials": "dns_inwx_url = https://api.domrobot.com/xmlrpc/\ndns_inwx_username = your_username\ndns_inwx_password = your_password\ndns_inwx_shared_secret = your_shared_secret optional", "credentials": "dns_inwx_url = https://api.domrobot.com/xmlrpc/\ndns_inwx_username = your_username\ndns_inwx_password = your_password\ndns_inwx_shared_secret = your_shared_secret optional",
@@ -391,6 +399,14 @@
"package_name": "certbot-dns-ionos", "package_name": "certbot-dns-ionos",
"version": "==2022.11.24" "version": "==2022.11.24"
}, },
"ip-projects": {
"credentials": "dns_ipp_acme_api_key = <API key>",
"dependencies": "",
"full_plugin_name": "dns-ipp",
"name": "ip-projects.de",
"package_name": "certbot-dns-ipp",
"version": "~=1.0.0"
},
"ispconfig": { "ispconfig": {
"credentials": "dns_ispconfig_username = myremoteuser\ndns_ispconfig_password = verysecureremoteuserpassword\ndns_ispconfig_endpoint = https://localhost:8080", "credentials": "dns_ispconfig_username = myremoteuser\ndns_ispconfig_password = verysecureremoteuserpassword\ndns_ispconfig_endpoint = https://localhost:8080",
"dependencies": "", "dependencies": "",
@@ -591,6 +607,14 @@
"package_name": "certbot-dns-route53", "package_name": "certbot-dns-route53",
"version": "=={{certbot-version}}" "version": "=={{certbot-version}}"
}, },
"scaleway": {
"credentials": "dns_scaleway_application_token = b3a0b9a9-3814-4f12-95b0-a7473bf8b306",
"dependencies": "",
"full_plugin_name": "dns-scaleway",
"name": "Scaleway",
"package_name": "certbot-dns-scaleway",
"version": "~=0.0.7"
},
"selectelv2": { "selectelv2": {
"credentials": "dns_selectel_api_v2_account_id = your_account_id\ndns_selectel_api_v2_project_name = your_project\ndns_selectel_api_v2_username = your_username\ndns_selectel_api_v2_password = your_password", "credentials": "dns_selectel_api_v2_account_id = your_account_id\ndns_selectel_api_v2_project_name = your_project\ndns_selectel_api_v2_username = your_username\ndns_selectel_api_v2_password = your_password",
"dependencies": "", "dependencies": "",
@@ -629,7 +653,7 @@
"full_plugin_name": "dns-tencentcloud", "full_plugin_name": "dns-tencentcloud",
"name": "Tencent Cloud", "name": "Tencent Cloud",
"package_name": "certbot-dns-tencentcloud", "package_name": "certbot-dns-tencentcloud",
"version": "~=2.0.2" "version": "~=2.1.1"
}, },
"timeweb": { "timeweb": {
"credentials": "dns_timeweb_api_key = XXXXXXXXXXXXXXXXXXX", "credentials": "dns_timeweb_api_key = XXXXXXXXXXXXXXXXXXX",
@@ -637,7 +661,7 @@
"full_plugin_name": "dns-timeweb", "full_plugin_name": "dns-timeweb",
"name": "Timeweb Cloud", "name": "Timeweb Cloud",
"package_name": "certbot-dns-timeweb", "package_name": "certbot-dns-timeweb",
"version": "~=1.0.1" "version": "~=2.0.0"
}, },
"transip": { "transip": {
"credentials": "dns_transip_username = my_username\ndns_transip_key_file = /etc/letsencrypt/transip-rsa.key", "credentials": "dns_transip_username = my_username\ndns_transip_key_file = /etc/letsencrypt/transip-rsa.key",
@@ -661,7 +685,7 @@
"full_plugin_name": "dns-websupport", "full_plugin_name": "dns-websupport",
"name": "Websupport.sk", "name": "Websupport.sk",
"package_name": "certbot-dns-websupport", "package_name": "certbot-dns-websupport",
"version": "~=2.0.1" "version": "~=5.0.0"
}, },
"wedos": { "wedos": {
"credentials": "dns_wedos_user = <wedos_registration>\ndns_wedos_auth = <wapi_password>", "credentials": "dns_wedos_user = <wedos_registration>\ndns_wedos_auth = <wapi_password>",
@@ -686,5 +710,13 @@
"name": "RcodeZero", "name": "RcodeZero",
"package_name": "certbot-dns-rcode0", "package_name": "certbot-dns-rcode0",
"version": "~=0.0.0.2" "version": "~=0.0.0.2"
},
"lws": {
"credentials": "dns_lws_login = 123456\ndns_lws_api_key = YOUR_API_KEY",
"dependencies": "",
"full_plugin_name": "dns-lws",
"name": "LWS",
"package_name": "certbot-dns-lws",
"version": "~=1.0.0"
} }
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import knex from "knex"; import knex from "knex";
import {configGet, configHas} from "./lib/config.js"; import { configGet, configHas } from "./lib/config.js";
let instance = null; let instance = null;
@@ -23,8 +23,8 @@ const generateDbConfig = () => {
user: cfg.user, user: cfg.user,
password: cfg.password, password: cfg.password,
database: cfg.name, database: cfg.name,
port: cfg.port, port: cfg.port,
...(cfg.ssl ? { ssl: cfg.ssl } : {}) ...(cfg.ssl ? { ssl: cfg.ssl } : {}),
}, },
migrations: { migrations: {
tableName: "migrations", tableName: "migrations",
@@ -37,6 +37,6 @@ const getInstance = () => {
instance = knex(generateDbConfig()); instance = knex(generateDbConfig());
} }
return instance; return instance;
} };
export default getInstance; export default getInstance;
+3 -1
View File
@@ -26,7 +26,9 @@ async function appStart() {
}) })
.then(() => { .then(() => {
internalCertificate.initTimer(); internalCertificate.initTimer();
internalIpRanges.initTimer(); if (IP_RANGES_FETCH_ENABLED) {
internalIpRanges.initTimer();
}
const server = app.listen(3000, () => { const server = app.listen(3000, () => {
logger.info(`Backend PID ${process.pid} listening on port 3000 ...`); logger.info(`Backend PID ${process.pid} listening on port 3000 ...`);
+7 -11
View File
@@ -161,12 +161,12 @@ const internal2fa = {
} }
const result = await verify({ const result = await verify({
token: code, token: code,
secret: auth.meta.totp_secret, secret: auth.meta.totp_secret,
guardrails: createGuardrails({ guardrails: createGuardrails({
MIN_SECRET_BYTES: 10, MIN_SECRET_BYTES: 10,
}), }),
}); });
if (!result.valid) { if (!result.valid) {
throw new errs.AuthError("Invalid verification code"); throw new errs.AuthError("Invalid verification code");
@@ -288,11 +288,7 @@ const internal2fa = {
}, },
getUserPasswordAuth: async (userId) => { getUserPasswordAuth: async (userId) => {
const auth = await authModel const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first();
.query()
.where("user_id", userId)
.andWhere("type", "password")
.first();
if (!auth) { if (!auth) {
throw new errs.ItemNotFoundError("Auth not found"); throw new errs.ItemNotFoundError("Auth not found");
+108 -43
View File
@@ -2,7 +2,10 @@ import fs from "node:fs";
import batchflow from "batchflow"; import batchflow from "batchflow";
import _ from "lodash"; import _ from "lodash";
import errs from "../lib/error.js"; import errs from "../lib/error.js";
import { isValid, unsafeValueError } from "../lib/nginx-values.js";
import { isMysql, isPostgres } from "../lib/config.js";
import utils from "../lib/utils.js"; import utils from "../lib/utils.js";
import db from "../db.js";
import { access as logger } from "../logger.js"; import { access as logger } from "../logger.js";
import accessListModel from "../models/access_list.js"; import accessListModel from "../models/access_list.js";
import accessListAuthModel from "../models/access_list_auth.js"; import accessListAuthModel from "../models/access_list_auth.js";
@@ -15,6 +18,36 @@ const omissions = () => {
return ["is_deleted"]; return ["is_deleted"];
}; };
/**
* Find proxy hosts that reference an access list in their locations JSON.
*
* @param {Integer} accessListId
* @returns {Promise<Array>}
*/
const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
let result;
if (isMysql()) {
const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)", [
searchObj,
"$",
]);
} else if (isPostgres()) {
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb", [
JSON.stringify([{ access_list_id: accessListId }]),
]);
} else {
result = await db().raw("SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?", [
`%"access_list_id":${accessListId}%`,
]);
}
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
if (!Array.isArray(result)) {
return result?.rows || [];
}
return (Array.isArray(result[0]) ? result[0] : result) || [];
};
const internalAccessList = { const internalAccessList = {
/** /**
* @param {Access} access * @param {Access} access
@@ -66,7 +99,7 @@ const internalAccessList = {
id: data.id, id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"], expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
}, },
true // skip masking true, // skip masking
); );
// Audit log // Audit log
@@ -180,13 +213,34 @@ const internalAccessList = {
id: data.id, id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"], expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
}, },
true // skip masking true, // skip masking
); );
await internalAccessList.build(freshRow) await internalAccessList.build(freshRow);
if (Number.parseInt(freshRow.proxy_host_count, 10)) { if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts); await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
} }
// Also regenerate configs for proxy hosts that reference this access list in their locations
const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
if (locationHostRows?.length) {
const locationHostIds = locationHostRows
.map((r) => r.id)
.filter((id) => {
// Exclude hosts already regenerated above
return !freshRow.proxy_hosts?.find((h) => h.id === id);
});
if (locationHostIds.length) {
const locationHosts = await proxyHostModel
.query()
.where("is_deleted", 0)
.whereIn("id", locationHostIds)
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
}
}
await internalNginx.reload(); await internalNginx.reload();
return internalAccessList.maskItems(freshRow); return internalAccessList.maskItems(freshRow);
}, },
@@ -202,17 +256,13 @@ const internalAccessList = {
*/ */
get: async (access, data, skipMasking) => { get: async (access, data, skipMasking) => {
const thisData = data || {}; const thisData = data || {};
const accessData = await access.can("access_lists:get", thisData.id) const accessData = await access.can("access_lists:get", thisData.id);
const query = accessListModel const query = accessListModel
.query() .query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count")) .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () { .leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn( this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
"proxy_host.is_deleted",
"=",
0,
);
}) })
.where("access_list.is_deleted", 0) .where("access_list.is_deleted", 0)
.andWhere("access_list.id", thisData.id) .andWhere("access_list.id", thisData.id)
@@ -267,28 +317,41 @@ const internalAccessList = {
// 4. audit log // 4. audit log
// 1. update row to be deleted // 1. update row to be deleted
await accessListModel await accessListModel.query().where("id", row.id).patch({
.query() is_deleted: 1,
.where("id", row.id) });
.patch({
is_deleted: 1,
});
// 2. update any proxy hosts that were using it (ignoring permissions) // 2. update any proxy hosts that were using it (ignoring permissions)
if (row.proxy_hosts) { const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
await proxyHostModel if (affectedHostIds.size) {
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
}
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
for (const { id: hostId } of locationHostRows) {
const host = await proxyHostModel.query().where("id", hostId).first();
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
const updatedLocations = host.locations.map((loc) => {
if (loc.access_list_id === row.id) {
return { ...loc, access_list_id: 0 };
}
return loc;
});
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
affectedHostIds.add(hostId);
}
}
// 3. reconfigure those hosts from fresh rows, then reload nginx
if (affectedHostIds.size) {
const affectedHosts = await proxyHostModel
.query() .query()
.where("access_list_id", "=", row.id) .where("is_deleted", 0)
.patch({ access_list_id: 0 }); .whereIn("id", [...affectedHostIds])
.allowGraph(proxyHostModel.defaultAllowGraph)
// 3. reconfigure those hosts, then reload nginx .withGraphFetched("[owner, certificate, access_list.[clients,items]]");
// set the access_list_id to zero for these items await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
row.proxy_hosts.map((_val, idx) => {
row.proxy_hosts[idx].access_list_id = 0;
return true;
});
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
} }
await internalNginx.reload(); await internalNginx.reload();
@@ -325,11 +388,7 @@ const internalAccessList = {
.query() .query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count")) .select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () { .leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn( this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
"proxy_host.is_deleted",
"=",
0,
);
}) })
.where("access_list.is_deleted", 0) .where("access_list.is_deleted", 0)
.groupBy("access_list.id") .groupBy("access_list.id")
@@ -371,10 +430,7 @@ const internalAccessList = {
* @returns {Promise} * @returns {Promise}
*/ */
getCount: async (userId, visibility) => { getCount: async (userId, visibility) => {
const query = accessListModel const query = accessListModel.query().count("id as count").where("is_deleted", 0);
.query()
.count("id as count")
.where("is_deleted", 0);
if (visibility !== "all") { if (visibility !== "all") {
query.andWhere("owner_user_id", userId); query.andWhere("owner_user_id", userId);
@@ -436,20 +492,29 @@ const internalAccessList = {
} }
// 2. create empty access file // 2. create empty access file
fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'}); fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
// 3. generate password for each user // 3. generate password for each user
if (list.items.length) { if (list.items.length) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
batchflow(list.items).sequential() batchflow(list.items)
.sequential()
.each((_i, item, next) => { .each((_i, item, next) => {
if (item.password?.length) { if (item.password?.length) {
// One "user:hash" line each; a ":" or line break in the name would corrupt the file
if (!isValid("accessUsername", item.username)) {
reject(unsafeValueError(`Access List #${list.id}: user name is not allowed`));
return;
}
logger.info(`Adding: ${item.username}`); logger.info(`Adding: ${item.username}`);
utils.execFile('openssl', ['passwd', '-apr1', item.password]) utils
.execFile("openssl", ["passwd", "-apr1", item.password])
.then((res) => { .then((res) => {
try { try {
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'}); fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
encoding: "utf8",
});
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
@@ -471,7 +536,7 @@ const internalAccessList = {
}); });
}); });
} }
} },
} };
export default internalAccessList; export default internalAccessList;
+1 -6
View File
@@ -3,7 +3,6 @@ import { castJsonIfNeed } from "../lib/helpers.js";
import auditLogModel from "../models/audit-log.js"; import auditLogModel from "../models/audit-log.js";
const internalAuditLog = { const internalAuditLog = {
/** /**
* All logs * All logs
* *
@@ -46,11 +45,7 @@ const internalAuditLog = {
get: async (access, data) => { get: async (access, data) => {
await access.can("auditlog:list"); await access.can("auditlog:list");
const query = auditLogModel const query = auditLogModel.query().andWhere("id", data.id).allowGraph("[user]").first();
.query()
.andWhere("id", data.id)
.allowGraph("[user]")
.first();
if (typeof data.expand !== "undefined" && data.expand !== null) { if (typeof data.expand !== "undefined" && data.expand !== null) {
query.withGraphFetched(`[${data.expand.join(", ")}]`); query.withGraphFetched(`[${data.expand.join(", ")}]`);
+381 -23
View File
@@ -24,8 +24,163 @@ const certbotCommand = "certbot";
const certbotLogsDir = "/data/logs"; const certbotLogsDir = "/data/logs";
const certbotWorkDir = "/tmp/letsencrypt-lib"; const certbotWorkDir = "/tmp/letsencrypt-lib";
// Certbot output lines that say nothing about why a request failed
const certbotNoise = [
/^Saving debug log/,
/^Ask for help/,
/^See the logfile/,
/^(- )+-?$/,
/^Processing \//,
/^Renewing an existing/,
/^Requesting a certificate/,
/^Plugins selected/,
/^Account registered/,
/^Waiting \d+ seconds/,
/^All renewals failed/,
/^\S+\/fullchain\.pem \(failure\)$/,
/^\d+ renew failure\(s\)/,
];
// Plain-language hints for failures that are common and fixable outside NPM.
// [pattern, hint, dnsProvider?]: a hint with a dnsProvider only applies to certificates using that
// provider, so a generic message (e.g. "Authentication error" from another DNS plugin) isn't
// reported as a Cloudflare problem.
const certbotHints = [
// Checked first: a rate limit is unambiguous and the most useful thing to report
[
/too many (certificates|failed authorizations|new orders)|rateLimited/i,
() => "Let's Encrypt rate limit reached. Wait before retrying (see https://letsencrypt.org/docs/rate-limits/).",
],
[
/expected \S+ to be a symlink/i,
() =>
"This certificate's files in /etc/letsencrypt were copied without their symlinks (live/ must link to archive/). Restore /etc/letsencrypt from a copy made with tar or rsync -a, or delete this certificate and create it again.",
],
[
/Renewal configuration file \S+ is broken/i,
() =>
"This certificate's files in /etc/letsencrypt are damaged or missing. Restore /etc/letsencrypt from a backup, or delete this certificate and create it again.",
],
[
/\b9109\b.*?location: ([0-9a-f.:]+)/i,
(m) => {
const ip = m[1].replace(/\.+$/, "");
return `Cloudflare refused the API token from this server's public IP (${ip}). The token has Client IP Address Filtering: add ${ip} to the token in Cloudflare (My Profile > API Tokens), or remove the filter, then renew again.`;
},
"cloudflare",
],
[
// certbot-dns-cloudflare errors read "<context>: <code> <message>", e.g.
// "Error determining zone_id: 9109 Invalid access token."
/: (6003|6111|9103|9109|10000) [A-Za-z]|Invalid request headers|Invalid access token|Authentication error/i,
() =>
"Cloudflare rejected the API token. Check the token value and that it has Zone > DNS > Edit for this domain's zone.",
"cloudflare",
],
[
/NXDOMAIN|DNS problem/i,
() => "Let's Encrypt could not look up the domain. Check the DNS records of every domain on the certificate.",
],
[
/Timeout during connect|Connection refused|Invalid response from/i,
() =>
"Let's Encrypt could not reach this server over HTTP (port 80) for the challenge. Check that the domain points here and port 80 is open, or use a DNS challenge.",
],
];
/**
* The values in a DNS credentials file ("key = value" lines), for redacting from certbot output.
*
* @param {String} [credentials]
* @returns {String[]}
*/
const credentialValues = (credentials) =>
`${credentials || ""}`
.split("\n")
.map((line) => line.replace(/^[^=:]*[=:]/, "").trim())
.filter((value) => value.length >= 4);
/**
* Removes anything that could be a secret from certbot output before it leaves the server.
* DNS plugins can echo provider errors verbatim, including request URLs that carry API keys in
* the query string (e.g. "401 Client Error ... for url: https://...?token=..."), so this strips
* the given credential values, URL query strings and userinfo, and key=value pairs whose name
* looks secret.
*
* @param {String} text
* @param {String[]} [secrets]
* @returns {String}
*/
const redactCertbotOutput = (text, secrets = []) => {
let out = `${text || ""}`;
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
out = out.split(secret).join("[REDACTED]");
}
return out
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]*@/gi, "$1[REDACTED]@")
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)\?[^\s#]*/gi, "$1?[REDACTED]")
.replace(
/\b([\w-]*(?:token|key|secret|password|passwd|pass|auth|credential|signature|sig)[\w-]*)(["']?\s*[=:]\s*["']?)[^\s"'&,;]+/gi,
"$1$2[REDACTED]",
);
};
/**
* Turns certbot's stderr into a message the UI can show: a hint for common causes plus certbot's own reason.
* The output is redacted first (see redactCertbotOutput), since DNS plugins can echo credentials.
*
* @param {String} stderr
* @param {String} [dnsProvider] The certificate's DNS provider, if it uses a DNS challenge
* @param {String[]} [secrets] Values that must never appear in the message
* @returns {String}
*/
const certbotFailureMessage = (stderr, dnsProvider, secrets) => {
const reason = redactCertbotOutput(stderr, secrets)
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !certbotNoise.some((re) => re.test(line)))
.join(" ")
.slice(0, 800);
for (const [pattern, hint, provider] of certbotHints) {
if (provider && provider !== dnsProvider) {
continue;
}
const match = reason.match(pattern);
if (match) {
return `${hint(match)} (Certbot: ${reason})`;
}
}
return reason ? `Certbot failed: ${reason}` : "Certbot failed. See /data/logs/letsencrypt.log for details.";
};
/**
* Runs certbot; a failure becomes a public error with the real reason instead of "Internal Error".
*
* @param {Array} args
* @param {Object} [opts]
* @param {String} [dnsProvider] Passed through so provider-specific hints only show for that provider
* @param {String} [credentials] The DNS credentials file contents, so its values are redacted from errors
* @returns {Promise}
*/
const runCertbot = async (args, opts, dnsProvider, credentials) => {
try {
return await utils.execFile(certbotCommand, args, opts);
} catch (err) {
if (err instanceof error.CommandError) {
const secrets = credentialValues(credentials);
logger.error(redactCertbotOutput(err.message, secrets));
// The raw error is not attached as `previous`: the API error handler returns
// `previous` to the client for certificate routes, and it holds unredacted output.
throw new error.ValidationError(certbotFailureMessage(err.message, dnsProvider, secrets));
}
throw err;
}
};
const omissions = () => { const omissions = () => {
return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials"]; return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials", "meta.certificate_key"];
}; };
const internalCertificate = { const internalCertificate = {
@@ -75,6 +230,7 @@ const internalCertificate = {
* instance of Certbot is already running.' * instance of Certbot is already running.'
*/ */
let sequence = Promise.resolve(); let sequence = Promise.resolve();
let renewed = 0;
certificates.forEach((certificate) => { certificates.forEach((certificate) => {
sequence = sequence.then(() => sequence = sequence.then(() =>
@@ -88,7 +244,11 @@ const internalCertificate = {
token: tokenModel(), token: tokenModel(),
}, },
{ id: certificate.id }, { id: certificate.id },
{ reload: false }, // one reload for the whole batch, below
) )
.then(() => {
renewed++;
})
.catch((err) => { .catch((err) => {
// Don't want to stop the train here, just log the error // Don't want to stop the train here, just log the error
logger.error(err.message); logger.error(err.message);
@@ -96,7 +256,11 @@ const internalCertificate = {
); );
}); });
return sequence; return sequence.then(() => {
if (renewed) {
return internalCertificate.reloadAfterRenew();
}
});
}) })
.then(() => { .then(() => {
logger.info("Completed SSL cert renew process"); logger.info("Completed SSL cert renew process");
@@ -395,12 +559,35 @@ const internalCertificate = {
*/ */
delete: async (access, data) => { delete: async (access, data) => {
await access.can("certificates:delete", data.id); await access.can("certificates:delete", data.id);
const row = await internalCertificate.get(access, { id: data.id }); const hostRelations = ["proxy_hosts", "redirection_hosts", "dead_hosts", "streams"];
const row = await internalCertificate.get(access, { id: data.id, expand: hostRelations });
if (!row?.id) { if (!row?.id) {
throw new error.ItemNotFoundError(data.id); throw new error.ItemNotFoundError(data.id);
} }
// A certificate that hosts still use can't be deleted: their rendered configs include its
// files, and revoking it would leave them serving a revoked certificate. Hosts may belong
// to other users, so only counts are reported.
const labels = {
proxy_hosts: "proxy host",
redirection_hosts: "redirection host",
dead_hosts: "404 host",
streams: "stream",
};
const usage = hostRelations
.map((rel) => ({ rel, count: row[rel]?.length || 0 }))
.filter((u) => u.count > 0)
.map((u) => `${u.count} ${labels[u.rel]}${u.count === 1 ? "" : "s"}`);
if (usage.length) {
throw new error.ValidationError(
`Certificate is in use by ${usage.join(", ")}. Remove it from them before deleting it.`,
);
}
for (const rel of hostRelations) {
delete row[rel];
}
await certificateModel.query().where("id", row.id).patch({ await certificateModel.query().where("id", row.id).patch({
is_deleted: 1, is_deleted: 1,
}); });
@@ -416,12 +603,47 @@ const internalCertificate = {
}); });
if (row.provider === "letsencrypt") { if (row.provider === "letsencrypt") {
// Revoke the cert // Revoke the cert. Errors are logged by revokeLetsEncryptSsl.
await internalCertificate.revokeLetsEncryptSsl(row); await internalCertificate.revokeLetsEncryptSsl(row);
// Certbot leaves the files behind when it can't revoke (e.g. a broken renewal config),
// so remove them either way. No host uses them; that was checked above.
// The row is already deleted and audited, so leftover files are logged, not thrown:
// an error here would report a completed delete as failed, and a retry would 404.
const leftover = internalCertificate.removeLetsEncryptFiles(row.id);
if (leftover.length) {
logger.warn(
`Certificate #${row.id} was deleted, but these files could not be removed and should be deleted by hand: ${leftover.join(", ")}`,
);
}
} }
return true; return true;
}, },
/**
* Removes a certificate's certbot files, as `certbot delete` would. Never throws.
*
* @param {Number} certificateId
* @returns {String[]} paths that could not be removed
*/
removeLetsEncryptFiles: (certificateId) => {
const paths = [
internalCertificate.getLiveCertPath(certificateId),
`/etc/letsencrypt/archive/npm-${certificateId}`,
`/etc/letsencrypt/renewal/npm-${certificateId}.conf`,
`/etc/letsencrypt/credentials/credentials-${certificateId}`,
];
const failed = [];
for (const p of paths) {
try {
fs.rmSync(p, { recursive: true, force: true });
} catch (err) {
logger.error(`Could not remove ${p}: ${err.message}`);
failed.push(p);
}
}
return failed;
},
/** /**
* All Certs * All Certs
* *
@@ -545,13 +767,15 @@ const internalCertificate = {
/** /**
* Validates that the certs provided are good. * Validates that the certs provided are good.
* No access required here, nothing is changed or stored. * Nothing is changed or stored, but the files go through openssl, so it needs the same access as creating one.
* *
* @param {Access} access
* @param {Object} data * @param {Object} data
* @param {Object} data.files * @param {Object} data.files
* @returns {Promise} * @returns {Promise}
*/ */
validate: (data) => { validate: async (access, data) => {
await access.can("certificates:create");
// Put file contents into an object // Put file contents into an object
const files = {}; const files = {};
_.map(data.files, (file, name) => { _.map(data.files, (file, name) => {
@@ -600,27 +824,75 @@ const internalCertificate = {
throw new error.ValidationError("Cannot upload certificates for this type of provider"); throw new error.ValidationError("Cannot upload certificates for this type of provider");
} }
const validations = await internalCertificate.validate(data); const validations = await internalCertificate.validate(access, data);
if (typeof validations.certificate === "undefined") { if (typeof validations.certificate === "undefined") {
throw new error.ValidationError("Certificate file was not provided"); throw new error.ValidationError("Certificate file was not provided");
} }
// row came through get(), which omits meta.certificate_key so it never goes out over
// the API. update() replaces meta wholesale, so build on the stored meta (read from the
// model directly) or an upload without a key would erase the stored one.
const stored = await certificateModel.query().where("id", row.id).first();
const meta = { ...(stored?.meta || {}) };
const uploaded = [];
_.map(data.files, (file, name) => { _.map(data.files, (file, name) => {
if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) { if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) {
row.meta[name] = file.data.toString(); meta[name] = file.data.toString();
uploaded.push(name);
} }
}); });
// Fail before anything is written: without a key, writeCustomCert would overwrite
// fullchain.pem and then fail on privkey.pem, leaving nginx with a mismatched pair.
if (!meta.certificate_key) {
throw new error.ValidationError("Certificate Key file was not provided");
}
// The key may be the stored one (a certificate-only upload), so check the pair belongs
// together; a mismatch would make nginx -t fail for every host on the next reload.
await internalCertificate.checkKeyMatchesCertificate(meta.certificate, meta.certificate_key);
const certificate = await internalCertificate.update(access, { const certificate = await internalCertificate.update(access, {
id: data.id, id: data.id,
expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
domain_names: validations.certificate.cn ? [validations.certificate.cn] : [], domain_names: validations.certificate.cn ? [validations.certificate.cn] : [],
meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later meta: _.clone(meta), // Prevent the update method from changing this value that we'll use later
}); });
certificate.meta = row.meta; certificate.meta = meta;
await internalCertificate.writeCustomCert(certificate); await internalCertificate.writeCustomCert(certificate);
return _.pick(row.meta, internalCertificate.allowedSslFiles); // Only echo back what was uploaded in this request, never the stored key
return _.pick(meta, uploaded);
},
/**
* Throws unless the private key is the one the certificate was issued for, by comparing the
* public key derived from each.
*
* @param {String} certificate PEM certificate (the leaf comes first)
* @param {String} privateKey PEM private key
* @returns {Promise}
*/
checkKeyMatchesCertificate: async (certificate, privateKey) => {
const certPath = await tempWrite(certificate);
const keyPath = await tempWrite(privateKey);
try {
const [certPub, keyPub] = await Promise.all([
utils.execFile("openssl", ["x509", "-in", certPath, "-pubkey", "-noout"]),
utils.execFile("openssl", ["pkey", "-in", keyPath, "-pubout"]),
]);
if (!certPub.trim() || certPub.trim() !== keyPub.trim()) {
throw new error.ValidationError("Certificate does not match the Certificate Key");
}
} catch (err) {
if (err instanceof error.ValidationError) {
throw err;
}
throw new error.ValidationError("Could not check that the Certificate matches the Certificate Key");
} finally {
fs.rmSync(certPath, { force: true });
fs.rmSync(keyPath, { force: true });
}
}, },
/** /**
@@ -811,7 +1083,7 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
const result = await utils.execFile(certbotCommand, args, adds.opts); const result = await runCertbot(args, adds.opts);
logger.success(result); logger.success(result);
return result; return result;
}, },
@@ -860,7 +1132,8 @@ const internalCertificate = {
if (hasConfigArg) { if (hasConfigArg) {
args.push(`--${dnsPlugin.full_plugin_name}-credentials`, credentialsLocation); args.push(`--${dnsPlugin.full_plugin_name}-credentials`, credentialsLocation);
} }
if (certificate.meta.propagation_seconds !== undefined) { // The route53 plugin waits for the change through the AWS API and has no propagation option
if (certificate.meta.propagation_seconds !== undefined && certificate.meta.dns_provider !== "route53") {
args.push( args.push(
`--${dnsPlugin.full_plugin_name}-propagation-seconds`, `--${dnsPlugin.full_plugin_name}-propagation-seconds`,
certificate.meta.propagation_seconds.toString(), certificate.meta.propagation_seconds.toString(),
@@ -878,13 +1151,28 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
try { try {
const result = await utils.execFile(certbotCommand, args, adds.opts); const result = await runCertbot(
args,
adds.opts,
certificate.meta.dns_provider,
certificate.meta.dns_provider_credentials,
);
logger.info(result); logger.info(result);
return result; return result;
} catch (err) { } finally {
// Don't fail if file does not exist, so no need for action in the callback // Remove the credentials file whether certbot succeeded or failed.
//
// This cleanup used to sit in a catch block, so it only ran when issuance FAILED.
// A certificate that issued successfully left its DNS provider API credentials in
// /etc/letsencrypt/credentials for the entire life of that certificate. Nothing
// reads the file between certbot runs, so there is no reason to keep it:
// renewLetsEncryptSslWithDnsChallenge() writes it again immediately before each
// renewal.
//
// unlink is fire-and-forget with an empty callback. If the file is already gone
// that is the end state we wanted anyway, and a missing file must never turn a
// successful issuance into a failure.
fs.unlink(credentialsLocation, () => {}); fs.unlink(credentialsLocation, () => {});
throw err;
} }
}, },
@@ -894,7 +1182,8 @@ const internalCertificate = {
* @param {Number} data.id * @param {Number} data.id
* @returns {Promise} * @returns {Promise}
*/ */
renew: async (access, data) => { renew: async (access, data, options = {}) => {
const reload = options.reload !== false;
await access.can("certificates:update", data); await access.can("certificates:update", data);
const certificate = await internalCertificate.get(access, data); const certificate = await internalCertificate.get(access, data);
@@ -920,12 +1209,34 @@ const internalCertificate = {
meta: updatedCertificate, meta: updatedCertificate,
}); });
// The renewal has succeeded and been recorded at this point. A reload failure
// (e.g. an unrelated host's config is broken) must not turn it into an error;
// nginx picks the new files up on the next successful reload.
if (reload) {
await internalCertificate.reloadAfterRenew();
}
return updatedCertificate; return updatedCertificate;
} }
throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed"); throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");
}, },
/**
* Reloads nginx so it serves renewed certificate files. Errors are logged, not thrown.
*
* @returns {Promise<Boolean>} whether the reload succeeded
*/
reloadAfterRenew: async () => {
try {
await internalNginx.reload();
return true;
} catch (err) {
logger.error(`Certificate renewed but nginx could not be reloaded: ${err.message}`);
return false;
}
},
/** /**
* @param {Object} certificate the certificate row * @param {Object} certificate the certificate row
* @returns {Promise} * @returns {Promise}
@@ -962,7 +1273,7 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
const result = await utils.execFile(certbotCommand, args, adds.opts); const result = await runCertbot(args, adds.opts);
logger.info(result); logger.info(result);
return result; return result;
}, },
@@ -981,6 +1292,43 @@ const internalCertificate = {
`Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`, `Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
); );
// certbot reads the DNS credentials back from the path recorded in the renewal config
// it wrote at issuance time, for example:
//
// authenticator = dns-cloudflare
// dns_cloudflare_credentials = /etc/letsencrypt/credentials/credentials-27
//
// so the file has to be present for the duration of this run. Write it here and remove
// it again below rather than leaving it on disk between renewals.
//
// Leaving it is an avoidable exposure. Anything running as root - a compromised
// process, a script, malware - can read the token and use it to issue valid Let's
// Encrypt certificates for the domain. Those certificates are genuinely trusted, so
// traffic presented with them passes TLS inspection, IDS/IPS and DLP that would
// otherwise flag it, and an exfiltration path built on them looks like ordinary
// HTTPS. The exposure window should be one certbot run, not the life of the
// certificate.
//
// The value is not on the certificate object we were handed: renew() sources that from
// internalCertificate.get(), which pipes the row through utils.omitRow(omissions()) so
// meta.dns_provider_credentials can never travel out over the API. Read the row from
// the model directly to get at it.
const row = await certificateModel.query().where("id", certificate.id).first();
const credentials = row?.meta?.dns_provider_credentials;
const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
if (credentials) {
fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true });
fs.writeFileSync(credentialsLocation, credentials, { mode: 0o600 });
} else {
// Nothing stored to write. A certificate issued under the previous behaviour may
// still have its file on disk; leave it be and let certbot decide. Throwing here
// would break a renewal that would otherwise have succeeded.
logger.warn(
`No stored DNS credentials for Cert #${certificate.id}; relying on any existing ${credentialsLocation}`,
);
}
const args = [ const args = [
"renew", "renew",
"--force-renewal", "--force-renewal",
@@ -1008,9 +1356,19 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
const result = await utils.execFile(certbotCommand, args, adds.opts); try {
logger.info(result); const result = await runCertbot(args, adds.opts, certificate.meta.dns_provider, credentials);
return result; logger.info(result);
return result;
} finally {
// Only clean up a file we put there ourselves. If `credentials` came back empty we
// wrote nothing, and an older file left on disk by the previous behaviour is the
// only thing keeping that certificate renewable - deleting it would break the next
// run for no gain.
if (credentials) {
fs.unlink(credentialsLocation, () => {});
}
}
}, },
/** /**
@@ -1042,7 +1400,7 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
try { try {
const result = await utils.execFile(certbotCommand, args, adds.opts); const result = await runCertbot(args, adds.opts);
await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`); await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`);
logger.info(result); logger.info(result);
return result; return result;
+30 -37
View File
@@ -54,9 +54,9 @@ const internalDeadHost = {
thisData.advanced_config = ""; thisData.advanced_config = "";
} }
const row = await deadHostModel.query() internalHost.assertAdvancedConfigAllowed(access, thisData);
.insertAndFetch(thisData)
.then(utils.omitRow(omissions())); const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog.add(access, {
@@ -132,6 +132,9 @@ const internalDeadHost = {
); );
} }
// Before the certificate is created, so a refused request doesn't leave one behind
internalHost.assertAdvancedConfigAllowed(access, data, row);
if (createCertificate) { if (createCertificate) {
const cert = await internalCertificate.createQuickCertificate(access, { const cert = await internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names, domain_names: data.domain_names || row.domain_names,
@@ -153,12 +156,8 @@ const internalDeadHost = {
thisData = internalHost.cleanSslHstsData(thisData, row); thisData = internalHost.cleanSslHstsData(thisData, row);
// do the row update // do the row update
await deadHostModel await deadHostModel.query().where({ id: data.id }).patch(data);
.query()
.where({id: data.id})
.patch(data);
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog.add(access, {
@@ -168,15 +167,18 @@ const internalDeadHost = {
meta: thisData, meta: thisData,
}); });
const thisRow = await internalDeadHost const thisRow = await internalDeadHost.get(access, {
.get(access, { id: thisData.id,
id: thisData.id, expand: ["owner", "certificate"],
expand: ["owner", "certificate"], });
});
// Configure nginx // Configure nginx
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", row); if (!thisRow.enabled) {
row.meta = newMeta; // No need to add nginx config if host is disabled
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
}
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", thisRow);
thisRow.meta = newMeta;
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions()); return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
}, },
@@ -224,18 +226,15 @@ const internalDeadHost = {
* @returns {Promise} * @returns {Promise}
*/ */
delete: async (access, data) => { delete: async (access, data) => {
await access.can("dead_hosts:delete", data.id) await access.can("dead_hosts:delete", data.id);
const row = await internalDeadHost.get(access, { id: data.id }); const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) { if (!row?.id) {
throw new errs.ItemNotFoundError(data.id); throw new errs.ItemNotFoundError(data.id);
} }
await deadHostModel await deadHostModel.query().where("id", row.id).patch({
.query() is_deleted: 1,
.where("id", row.id) });
.patch({
is_deleted: 1,
});
// Delete Nginx Config // Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row); await internalNginx.deleteConfig("dead_host", row);
@@ -259,7 +258,7 @@ const internalDeadHost = {
* @returns {Promise} * @returns {Promise}
*/ */
enable: async (access, data) => { enable: async (access, data) => {
await access.can("dead_hosts:update", data.id) await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, { const row = await internalDeadHost.get(access, {
id: data.id, id: data.id,
expand: ["certificate", "owner"], expand: ["certificate", "owner"],
@@ -273,12 +272,9 @@ const internalDeadHost = {
row.enabled = 1; row.enabled = 1;
await deadHostModel await deadHostModel.query().where("id", row.id).patch({
.query() enabled: 1,
.where("id", row.id) });
.patch({
enabled: 1,
});
// Configure nginx // Configure nginx
await internalNginx.configure(deadHostModel, "dead_host", row); await internalNginx.configure(deadHostModel, "dead_host", row);
@@ -301,7 +297,7 @@ const internalDeadHost = {
* @returns {Promise} * @returns {Promise}
*/ */
disable: async (access, data) => { disable: async (access, data) => {
await access.can("dead_hosts:update", data.id) await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, { id: data.id }); const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) { if (!row?.id) {
throw new errs.ItemNotFoundError(data.id); throw new errs.ItemNotFoundError(data.id);
@@ -312,12 +308,9 @@ const internalDeadHost = {
row.enabled = 0; row.enabled = 0;
await deadHostModel await deadHostModel.query().where("id", row.id).patch({
.query() enabled: 0,
.where("id", row.id) });
.patch({
enabled: 0,
});
// Delete Nginx Config // Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row); await internalNginx.deleteConfig("dead_host", row);
@@ -342,7 +335,7 @@ const internalDeadHost = {
* @returns {Promise} * @returns {Promise}
*/ */
getAll: async (access, expand, searchQuery) => { getAll: async (access, expand, searchQuery) => {
const accessData = await access.can("dead_hosts:list") const accessData = await access.can("dead_hosts:list");
const query = deadHostModel const query = deadHostModel
.query() .query()
.where("is_deleted", 0) .where("is_deleted", 0)
+103
View File
@@ -1,10 +1,110 @@
import _ from "lodash"; import _ from "lodash";
import errs from "../lib/error.js";
import { castJsonIfNeed } from "../lib/helpers.js"; import { castJsonIfNeed } from "../lib/helpers.js";
import deadHostModel from "../models/dead_host.js"; import deadHostModel from "../models/dead_host.js";
import proxyHostModel from "../models/proxy_host.js"; import proxyHostModel from "../models/proxy_host.js";
import redirectionHostModel from "../models/redirection_host.js"; import redirectionHostModel from "../models/redirection_host.js";
const internalHost = { const internalHost = {
/**
* Only admins may set or change advanced_config (raw nginx config). Throws for anyone else.
* On create (no `row`), any non-empty advanced_config is refused, including on locations.
* On update, the host's advanced_config must be unchanged (or omitted), and locations must
* only carry advanced configs the host already has (see assertLocationAdvancedConfigUnchanged).
*
* @param {Access} access
* @param {Object} data incoming host data; data.locations may be filled in on update
* @param {Object} [row] the existing host, when updating
* @throws {PermissionError}
*/
assertAdvancedConfigAllowed: (access, data, row) => {
if (access.isAdmin?.()) {
return;
}
const deny = () => {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
};
if (!row) {
const hasConfig = (v) => typeof v === "string" && v.trim() !== "";
if (hasConfig(data.advanced_config)) {
deny();
}
if (Array.isArray(data.locations) && data.locations.some((loc) => hasConfig(loc?.advanced_config))) {
deny();
}
return;
}
if (typeof data.advanced_config !== "undefined" && data.advanced_config !== row.advanced_config) {
deny();
}
if (Array.isArray(data.locations)) {
internalHost.assertLocationAdvancedConfigUnchanged(data.locations, row.locations);
}
},
/**
* For non-admins: every location's advanced_config must be empty or one that already exists
* on this host. Matching is by value, not path, so renaming or reordering a location that
* carries admin-written config is allowed, but new or edited config is not. Each existing
* config can be used once (no duplicating it onto other paths), and same-path matches are
* taken first. Clearing a config is allowed: deleting the location is, and has the same
* effect, and a location deleted and re-added at the same path arrives with an empty config.
* A location that omits advanced_config keeps the one at its path, unless another location
* explicitly carries it. Mutates `locations`.
*
* @param {Array} locations incoming locations
* @param {Array} [existingLocations] the host's current locations
* @throws {PermissionError}
*/
assertLocationAdvancedConfigUnchanged: (locations, existingLocations) => {
const existing = Array.isArray(existingLocations) ? existingLocations : [];
const advAtPath = (path) => existing.find((l) => l.path === path)?.advanced_config || "";
const pool = existing.map((l) => l.advanced_config || "").filter(Boolean);
const take = (value) => {
const i = pool.indexOf(value);
if (i === -1) {
return false;
}
pool.splice(i, 1);
return true;
};
const deny = () => {
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
};
// Pass 1: locations that keep the config at their own path
const unresolved = [];
const omitted = [];
for (const loc of locations) {
const atPath = advAtPath(loc.path);
if (typeof loc.advanced_config === "undefined") {
omitted.push(loc);
} else if ((loc.advanced_config || "") === atPath && (!atPath || take(atPath))) {
// unchanged
} else {
unresolved.push(loc);
}
}
// Pass 2: renamed/moved locations may carry an existing config not already claimed, or
// none at all
for (const loc of unresolved) {
if (loc.advanced_config && !take(loc.advanced_config)) {
deny();
}
}
// Pass 3: locations that omit advanced_config keep the one at their path, if no location
// that names it explicitly has claimed it. Running this last stops a new location at an
// old path taking the config away from the location it was renamed to.
for (const loc of omitted) {
const atPath = advAtPath(loc.path);
loc.advanced_config = atPath && take(atPath) ? atPath : "";
}
},
/** /**
* Makes sure that the ssl_* and hsts_* fields play nicely together. * Makes sure that the ssl_* and hsts_* fields play nicely together.
* ie: if there is no cert, then force_ssl is off. * ie: if there is no cert, then force_ssl is off.
@@ -79,6 +179,9 @@ const internalHost = {
redirection_hosts: [], redirection_hosts: [],
}; };
// No relations are eager-loaded here: this scans every host just to match domains, and
// the few that match are rendered through internalNginx.generateConfig(), which loads
// their certificate and access list itself (loadRenderRelations).
const proxyRes = await proxyHostModel.query().where("is_deleted", 0); const proxyRes = await proxyHostModel.query().where("is_deleted", 0);
responseObject.proxy_hosts = internalHost._getHostsWithDomains(proxyRes, domainNames); responseObject.proxy_hosts = internalHost._getHostsWithDomains(proxyRes, domainNames);
responseObject.total_count += responseObject.proxy_hosts.length; responseObject.total_count += responseObject.proxy_hosts.length;
+229
View File
@@ -0,0 +1,229 @@
import fs from "node:fs";
import errs from "../lib/error.js";
import internalDeadHost from "./dead-host.js";
import internalProxyHost from "./proxy-host.js";
import internalRedirectionHost from "./redirection-host.js";
import internalStream from "./stream.js";
const SYSTEM_LOG_FILE = "/data/logs/backend.log";
const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log";
// Matches the access_log/error_log paths written by the nginx templates
// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf).
// This is a fixed, server-side lookup table - a host log path is always derived
// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied
// path or filename, so there is no path-traversal surface here.
const HOST_FILE_PREFIX = {
proxy: "proxy-host",
redirection: "redirection-host",
dead: "dead-host",
stream: "stream",
};
const DEFAULT_LINES = 200;
const MAX_LINES = 1000;
const CHUNK_SIZE = 64 * 1024;
// Never scan further back than this, regardless of how many lines were requested,
// so a huge or pathological log file can't turn a single request into unbounded I/O.
const MAX_SCAN_BYTES = 5 * 1024 * 1024;
/**
* Reads at most `maxLines` lines from the end of a file, without loading the
* whole file into memory. Reads backwards in fixed-size chunks until enough
* newlines have been seen, the start of the file is reached, or the hard
* MAX_SCAN_BYTES ceiling is hit.
*
* @param {String} filePath
* @param {Number} maxLines
* @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>}
*/
const readLastLines = async (filePath, maxLines) => {
let handle;
try {
handle = await fs.promises.open(filePath, "r");
const stat = await handle.stat();
const { size } = stat;
if (size === 0) {
return { lines: [], size: 0, truncated: false };
}
let position = size;
let scanned = 0;
let newlineCount = 0;
const chunks = [];
while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) {
const readSize = Math.min(CHUNK_SIZE, position);
position -= readSize;
const buffer = Buffer.alloc(readSize);
await handle.read(buffer, 0, readSize, position);
scanned += readSize;
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === 0x0a) newlineCount++;
}
chunks.unshift(buffer);
}
const truncated = position > 0 && scanned >= MAX_SCAN_BYTES;
// If we didn't start reading from byte 0, the first line in our buffer is only
// partial *unless* it happens that `position` landed exactly on a line boundary
// (the byte right before it is a newline) - check that one byte to avoid
// silently dropping a perfectly valid line.
let startsOnLineBoundary = position === 0;
if (position > 0) {
const boundaryByte = Buffer.alloc(1);
await handle.read(boundaryByte, 0, 1, position - 1);
startsOnLineBoundary = boundaryByte[0] === 0x0a;
}
const text = Buffer.concat(chunks).toString("utf8");
const allLines = text.split("\n");
if (!startsOnLineBoundary && allLines.length > 0) {
allLines.shift();
}
// Drop the trailing empty element caused by a final trailing newline.
if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
allLines.pop();
}
return { lines: allLines.slice(-maxLines), size, truncated };
} finally {
if (handle) {
await handle.close();
}
}
};
/**
* Resolves a validated {type, host_type, host_id, channel} selection to the
* fixed, absolute path of the log file on disk. Throws if the combination
* isn't a recognised source.
*
* `channel` ("access" | "error") picks which of the two log files nginx writes
* per host - it's deliberately not named "stream" to avoid confusion with the
* "stream" host_type (TCP/UDP stream hosts).
*
* @param {Object} data
* @returns {String}
*/
const resolveFilePath = (data) => {
switch (data.type) {
case "system":
return SYSTEM_LOG_FILE;
case "letsencrypt":
return LETSENCRYPT_LOG_FILE;
case "host": {
const prefix = HOST_FILE_PREFIX[data.host_type];
if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) {
throw new errs.ValidationError("Invalid host log source");
}
return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`;
}
default:
throw new errs.ItemNotFoundError(data.type);
}
};
/**
* @param {Array} rows
* @returns {Array}
*/
const toHostOptions = (rows) =>
rows.map((row) => ({
id: row.id,
label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`,
}));
const internalLogViewer = {
/**
* Lists the log sources available for the log viewer: the system (backend)
* log, the Let's Encrypt (certbot) log, and one entry per host the caller
* can see, for each host type.
*
* @param {Access} access
* @returns {Promise}
*/
listSources: async (access) => {
await access.can("logs:list");
const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([
internalProxyHost.getAll(access),
internalRedirectionHost.getAll(access),
internalDeadHost.getAll(access),
internalStream.getAll(access),
]);
return {
system: { label: "System" },
letsencrypt: { label: "Let's Encrypt" },
hosts: {
proxy: toHostOptions(proxyHosts),
redirection: toHostOptions(redirectionHosts),
dead: toHostOptions(deadHosts),
stream: streams.map((row) => ({
id: row.id,
label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`,
})),
},
};
},
/**
* Returns the last N lines of the requested log source, optionally
* filtered by level and/or a plain-text search term.
*
* @param {Access} access
* @param {Object} data
* @param {String} data.type "system" | "letsencrypt" | "host"
* @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream"
* @param {Number} [data.host_id]
* @param {String} [data.channel] "access" | "error"
* @param {Number} [data.lines]
* @param {String} [data.level]
* @param {String} [data.search]
* @returns {Promise}
*/
tail: async (access, data) => {
await access.can("logs:list");
const filePath = resolveFilePath(data);
const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES);
let result;
try {
result = await readLastLines(filePath, lines);
} catch (err) {
if (err.code === "ENOENT") {
return { lines: [], size: 0, truncated: false, exists: false };
}
throw err;
}
let outputLines = result.lines;
// Only the system log is written in our own "LEVEL [scope]" format - level
// filtering on nginx/certbot lines would just match nothing and look like an
// empty log, so the filter is a no-op for any other source.
if (data.type === "system" && data.level) {
const needle = ` ${data.level.toUpperCase().padEnd(7)} `;
outputLines = outputLines.filter((line) => line.includes(needle));
}
if (data.search) {
const needle = data.search.toLowerCase();
outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle));
}
return {
lines: outputLines,
size: result.size,
truncated: result.truncated,
exists: true,
};
},
};
export default internalLogViewer;
+164 -25
View File
@@ -1,10 +1,14 @@
import fs from "node:fs"; import fs from "node:fs";
import net from "node:net";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import _ from "lodash"; import _ from "lodash";
import errs from "../lib/error.js"; import errs from "../lib/error.js";
import { assertAccessListRenderable, assertHostRenderable, isTokenSafe, unsafeValueError } from "../lib/nginx-values.js";
import utils from "../lib/utils.js"; import utils from "../lib/utils.js";
import { debug, nginx as logger } from "../logger.js"; import { debug, nginx as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import certificateModel from "../models/certificate.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -34,12 +38,33 @@ const internalNginx = {
// We're deleting this config regardless. // We're deleting this config regardless.
// Don't throw errors, as the file may not exist at all // Don't throw errors, as the file may not exist at all
// Delete the .err file too // Delete the .err file too
return internalNginx.deleteConfig(host_type, host, false, true); return internalNginx.deleteConfig(host_type, host, true);
}) })
.then(() => { .then(() => {
return internalNginx.generateConfig(host_type, host); return internalNginx.generateConfig(host_type, host).then(
() => true,
(err) => {
if (!err.unsafeNginxValue) {
throw err;
}
// Nothing was written. Report it on the host like a failed nginx test.
logger.error(err.message);
combined_meta = _.assign({}, host.meta, {
nginx_online: false,
nginx_err: err.message,
});
return model
.query()
.where("id", host.id)
.patch({ meta: combined_meta })
.then(() => false);
},
);
}) })
.then(() => { .then((generated) => {
if (!generated) {
return;
}
// Test nginx again and update meta with result // Test nginx again and update meta with result
return internalNginx return internalNginx
.test() .test()
@@ -83,10 +108,12 @@ const internalNginx = {
meta: combined_meta, meta: combined_meta,
}) })
.then(() => { .then(() => {
internalNginx.renameConfigAsError(host_type, host); // Keep the failed config as a .err file for inspection
return internalNginx.renameConfigAsError(host_type, host);
}) })
.then(() => { .then(() => {
return internalNginx.deleteConfig(host_type, host, true); // The rename removed the live config already, don't touch the .err file
return internalNginx.deleteConfig(host_type, host, false);
}); });
}); });
}) })
@@ -165,6 +192,38 @@ const internalNginx = {
host.locations[i], host.locations[i],
); );
// A location with its own access list overrides the host's,
// otherwise it inherits the host's access list
let locationAccessList = null;
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
locationAccessList = await accessListModel
.query()
.where("is_deleted", 0)
.andWhere("id", locationCopy.access_list_id)
.withGraphFetched("[clients,items]")
.first();
}
if (locationAccessList) {
assertAccessListRenderable(
`Proxy Host #${host.id} location ${locationCopy.path}`,
locationAccessList,
);
locationCopy.access_list = locationAccessList;
} else if (
locationCopy.access_list_id > 0 &&
locationCopy.access_list_id !== host.access_list?.id
) {
// The location names its own list but it's gone. Don't fall back to the
// host's (possibly less restrictive) list; _access.conf denies all instead.
logger.error(
`Proxy Host #${host.id} location "${locationCopy.path}" references Access List #${locationCopy.access_list_id} which does not exist, all access will be denied`,
);
locationCopy.access_list = null;
} else {
locationCopy.access_list_id = host.access_list_id;
locationCopy.access_list = host.access_list;
}
if (locationCopy.forward_host.indexOf("/") > -1) { if (locationCopy.forward_host.indexOf("/") > -1) {
const splitted = locationCopy.forward_host.split("/"); const splitted = locationCopy.forward_host.split("/");
@@ -176,7 +235,9 @@ const internalNginx = {
} }
}; };
locationRendering().then(() => resolve(renderedLocations)); locationRendering()
.then(() => resolve(renderedLocations))
.catch(reject);
}); });
}, },
@@ -185,11 +246,16 @@ const internalNginx = {
* @param {Object} host * @param {Object} host
* @returns {Promise} * @returns {Promise}
*/ */
generateConfig: (host_type, host_row) => { generateConfig: async (host_type, host_row) => {
// Prevent modifying the original object: // Prevent modifying the original object:
const host = JSON.parse(JSON.stringify(host_row)); const host = JSON.parse(JSON.stringify(host_row));
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type); const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
await internalNginx.loadRenderRelations(nice_host_type, host);
// Values are written into the config as-is; refuse any that could add directives
assertHostRenderable(nice_host_type, host);
debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2)); debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2));
const renderEngine = utils.getRenderEngine(); const renderEngine = utils.getRenderEngine();
@@ -217,10 +283,21 @@ const internalNginx = {
} }
// For redirection hosts, if the scheme is not http or https, set it to $scheme // For redirection hosts, if the scheme is not http or https, set it to $scheme
if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) { if (
nice_host_type === "redirection_host" &&
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
) {
host.forward_scheme = "$scheme"; host.forward_scheme = "$scheme";
} }
// A stream forwarding to an IPv6 literal must have the address wrapped in
// square brackets before nginx appends ":<port>". Without the brackets nginx
// reads the trailing ":<port>" as part of the address and rejects the upstream
// ("invalid port in upstream"), so the stream saves but never activates (#5740).
if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) {
host.forwarding_host = `[${host.forwarding_host}]`;
}
if (host.locations) { if (host.locations) {
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2)); //logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
origLocations = [].concat(host.locations); origLocations = [].concat(host.locations);
@@ -241,26 +318,79 @@ const internalNginx = {
// Set the IPv6 setting for the host // Set the IPv6 setting for the host
host.ipv6 = internalNginx.ipv6Enabled(); host.ipv6 = internalNginx.ipv6Enabled();
locationsPromise.then(() => { locationsPromise
renderEngine .then(() => {
.parseAndRender(template, host) renderEngine
.then((config_text) => { .parseAndRender(template, host)
fs.writeFileSync(filename, config_text, { encoding: "utf8" }); .then((config_text) => {
debug(logger, "Wrote config:", filename, config_text); fs.writeFileSync(filename, config_text, { encoding: "utf8" });
debug(logger, "Wrote config:", filename, config_text);
// Restore locations array // Restore locations array
host.locations = origLocations; host.locations = origLocations;
resolve(true); resolve(true);
}) })
.catch((err) => { .catch((err) => {
debug(logger, `Could not write ${filename}:`, err.message); debug(logger, `Could not write ${filename}:`, err.message);
reject(new errs.ConfigurationError(err.message)); reject(new errs.ConfigurationError(err.message));
}); });
}); })
.catch((err) => {
debug(logger, `Could not render locations for ${filename}:`, err.message);
reject(new errs.ConfigurationError(err.message));
});
}); });
}, },
/**
* The templates rely on the certificate and access list relations being loaded.
* Not every caller fetches them (or fetches them fully), and when they're missing
* the config silently renders without SSL or without any access rules (#5919).
* Load anything that is referenced but not loaded onto the given host object.
*
* @param {String} host_type
* @param {Object} host
* @returns {Promise}
*/
loadRenderRelations: async (host_type, host) => {
if (host.certificate_id > 0 && host.certificate?.id !== host.certificate_id) {
const certificate = await certificateModel
.query()
.where("is_deleted", 0)
.andWhere("id", host.certificate_id)
.first();
host.certificate = certificate ? JSON.parse(JSON.stringify(certificate)) : null;
}
if (host_type === "proxy_host" && host.access_list_id > 0) {
if (
host.access_list?.id !== host.access_list_id ||
!Array.isArray(host.access_list.clients) ||
!Array.isArray(host.access_list.items)
) {
const accessList = await accessListModel
.query()
.where("is_deleted", 0)
.andWhere("id", host.access_list_id)
.withGraphFetched("[clients,items]")
.first();
host.access_list = accessList ? JSON.parse(JSON.stringify(accessList)) : null;
}
if (!host.access_list) {
// _access.conf renders "deny all" when the list is missing, so fail closed
logger.error(
`Proxy Host #${host.id} references Access List #${host.access_list_id} which does not exist, all access will be denied`,
);
} else if (!host.access_list.clients.length && !host.access_list.items.length) {
logger.warn(
`Proxy Host #${host.id} uses Access List #${host.access_list_id} which has no clients or authorizations, no access restrictions will apply`,
);
}
}
},
/** /**
* This generates a temporary nginx config listening on port 80 for the domain names listed * This generates a temporary nginx config listening on port 80 for the domain names listed
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt * in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
@@ -273,6 +403,15 @@ const internalNginx = {
debug(logger, "Generating LetsEncrypt Request Config:", certificate); debug(logger, "Generating LetsEncrypt Request Config:", certificate);
const renderEngine = utils.getRenderEngine(); const renderEngine = utils.getRenderEngine();
const unsafe = (certificate.domain_names || []).find((name) => !isTokenSafe(name));
if (typeof unsafe !== "undefined") {
return Promise.reject(
unsafeValueError(
`Certificate #${certificate.id}: domain name ${JSON.stringify(unsafe)} is not allowed`,
),
);
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let template = null; let template = null;
const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`; const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
@@ -375,8 +514,8 @@ const internalNginx = {
const config_file_err = `${config_file}.err`; const config_file_err = `${config_file}.err`;
return new Promise((resolve /*, reject*/) => { return new Promise((resolve /*, reject*/) => {
fs.unlink(config_file, () => { fs.unlink(config_file_err, () => {
// ignore result, continue // ignore result, a previous .err file may not exist
fs.rename(config_file, config_file_err, () => { fs.rename(config_file, config_file_err, () => {
// also ignore result, as this is a debugging informative file anyway // also ignore result, as this is a debugging informative file anyway
resolve(); resolve();
+32 -33
View File
@@ -2,7 +2,7 @@ import _ from "lodash";
import errs from "../lib/error.js"; import errs from "../lib/error.js";
import { castJsonIfNeed } from "../lib/helpers.js"; import { castJsonIfNeed } from "../lib/helpers.js";
import utils from "../lib/utils.js"; import utils from "../lib/utils.js";
import proxyHostModel from "../models/proxy_host.js"; import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
import internalAuditLog from "./audit-log.js"; import internalAuditLog from "./audit-log.js";
import internalCertificate from "./certificate.js"; import internalCertificate from "./certificate.js";
import internalHost from "./host.js"; import internalHost from "./host.js";
@@ -57,6 +57,8 @@ const internalProxyHost = {
thisData.advanced_config = ""; thisData.advanced_config = "";
} }
internalHost.assertAdvancedConfigAllowed(access, thisData);
return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
}) })
.then((row) => { .then((row) => {
@@ -83,7 +85,7 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list.[clients,items]"], expand: ["certificate", "owner", "access_list.[clients,items]"],
}); });
}) })
.then((row) => { .then(async (row) => {
// Configure nginx // Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => { return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
return row; return row;
@@ -155,6 +157,9 @@ const internalProxyHost = {
); );
} }
// Before the certificate is created, so a refused request doesn't leave one behind
internalHost.assertAdvancedConfigAllowed(access, thisData, row);
if (createCertificate) { if (createCertificate) {
return internalCertificate return internalCertificate
.createQuickCertificate(access, { .createQuickCertificate(access, {
@@ -208,7 +213,7 @@ const internalProxyHost = {
id: thisData.id, id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"], expand: ["owner", "certificate", "access_list.[clients,items]"],
}) })
.then((row) => { .then(async (row) => {
if (!row.enabled) { if (!row.enabled) {
// No need to add nginx config if host is disabled // No need to add nginx config if host is disabled
return row; return row;
@@ -243,7 +248,7 @@ const internalProxyHost = {
.first(); .first();
if (access_data.permission_visibility !== "all") { if (access_data.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1)); scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta);
} }
if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
@@ -323,10 +328,10 @@ const internalProxyHost = {
.then(() => { .then(() => {
return internalProxyHost.get(access, { return internalProxyHost.get(access, {
id: data.id, id: data.id,
expand: ["certificate", "owner", "access_list"], expand: ["certificate", "owner", "access_list.[clients,items]"],
}); });
}) })
.then((row) => { .then(async (row) => {
if (!row?.id) { if (!row?.id) {
throw new errs.ItemNotFoundError(data.id); throw new errs.ItemNotFoundError(data.id);
} }
@@ -336,27 +341,21 @@ const internalProxyHost = {
row.enabled = 1; row.enabled = 1;
return proxyHostModel await proxyHostModel.query().where("id", row.id).patch({
.query() enabled: 1,
.where("id", row.id) });
.patch({
enabled: 1, // Configure nginx
}) await internalNginx.configure(proxyHostModel, "proxy_host", row);
.then(() => {
// Configure nginx // Add to audit log
return internalNginx.configure(proxyHostModel, "proxy_host", row); await internalAuditLog.add(access, {
}) action: "enabled",
.then(() => { object_type: "proxy-host",
// Add to audit log object_id: row.id,
return internalAuditLog.add(access, { meta: _.omit(row, omissions()),
action: "enabled", });
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
});
})
.then(() => {
return true; return true;
}); });
}, },
@@ -430,7 +429,7 @@ const internalProxyHost = {
.orderBy(castJsonIfNeed("domain_names"), "ASC"); .orderBy(castJsonIfNeed("domain_names"), "ASC");
if (accessData.permission_visibility !== "all") { if (accessData.permission_visibility !== "all") {
query.andWhere("owner_user_id", access.token.getUserId(1)); scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta);
} }
// Query is used for searching // Query is used for searching
@@ -456,18 +455,18 @@ const internalProxyHost = {
* *
* @param {Number} user_id * @param {Number} user_id
* @param {String} visibility * @param {String} visibility
* @param {Object} [permissionsMeta] the user's permissions meta, as already loaded by access
* @returns {Promise} * @returns {Promise}
*/ */
getCount: (user_id, visibility) => { getCount: async (user_id, visibility, permissionsMeta) => {
const query = proxyHostModel.query().count("id as count").where("is_deleted", 0); const query = proxyHostModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") { if (visibility !== "all") {
query.andWhere("owner_user_id", user_id); scopeToOwnerOrAllowed(query, user_id, permissionsMeta);
} }
return query.first().then((row) => { const row = await query.first();
return Number.parseInt(row.count, 10); return Number.parseInt(row.count, 10);
});
}, },
}; };
+7 -2
View File
@@ -53,10 +53,12 @@ const internalRedirectionHost = {
// Fix for db field not having a default value // Fix for db field not having a default value
// for this optional field. // for this optional field.
if (typeof data.advanced_config === "undefined") { if (typeof thisData.advanced_config === "undefined") {
data.advanced_config = ""; thisData.advanced_config = "";
} }
internalHost.assertAdvancedConfigAllowed(access, thisData);
return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
}) })
.then((row) => { .then((row) => {
@@ -155,6 +157,9 @@ const internalRedirectionHost = {
); );
} }
// Before the certificate is created, so a refused request doesn't leave one behind
internalHost.assertAdvancedConfigAllowed(access, thisData, row);
if (createCertificate) { if (createCertificate) {
return internalCertificate return internalCertificate
.createQuickCertificate(access, { .createQuickCertificate(access, {
+5 -1
View File
@@ -15,7 +15,11 @@ const internalReport = {
const userId = access.token.getUserId(1); const userId = access.token.getUserId(1);
const promises = [ const promises = [
internalProxyHost.getCount(userId, access_data.permission_visibility), internalProxyHost.getCount(
userId,
access_data.permission_visibility,
access.getPermissions?.()?.meta,
),
internalRedirectionHost.getCount(userId, access_data.permission_visibility), internalRedirectionHost.getCount(userId, access_data.permission_visibility),
internalStream.getCount(userId, access_data.permission_visibility), internalStream.getCount(userId, access_data.permission_visibility),
internalDeadHost.getCount(userId, access_data.permission_visibility), internalDeadHost.getCount(userId, access_data.permission_visibility),
+12 -20
View File
@@ -34,26 +34,21 @@ export default {
.andWhere("is_disabled", 0) .andWhere("is_disabled", 0)
.first(); .first();
if (!user) { // An unknown, disabled or passwordless account goes through the same queries and the same password check
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); // as a real one, and fails with the same error: answering any sooner or any differently would tell anyone
} // trying addresses which ones have an account. There is no user 0, so its lookup finds nothing. An empty
// secret, which creating a user through the API can store, is no hash: bcrypt would reject it at once.
const auth = await authModel const auth = await authModel
.query() .query()
.where("user_id", "=", user.id) .where("user_id", "=", user?.id ?? 0)
.where("type", "=", "password") .where("type", "=", "password")
.first(); .first();
if (!auth) { const valid = auth?.secret
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH); ? await auth.verifyPassword(data.secret)
} : await authModel.verifyDummyPassword(data.secret);
if (!user || !valid) {
const valid = await auth.verifyPassword(data.secret); throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
if (!valid) {
throw new errs.AuthError(
ERROR_MESSAGE_INVALID_AUTH,
ERROR_MESSAGE_INVALID_AUTH_I18N,
);
} }
if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) { if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) {
@@ -171,7 +166,7 @@ export default {
} }
// Check scope // Check scope
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") { if (tokenData.scope?.[0] !== "2fa-challenge") {
throw new errs.AuthError("Invalid challenge token"); throw new errs.AuthError("Invalid challenge token");
} }
@@ -183,10 +178,7 @@ export default {
// Verify 2FA code // Verify 2FA code
const valid = await twoFactor.verifyForLogin(userId, code); const valid = await twoFactor.verifyForLogin(userId, code);
if (!valid) { if (!valid) {
throw new errs.AuthError( throw new errs.AuthError(ERROR_MESSAGE_INVALID_2FA, ERROR_MESSAGE_INVALID_2FA_I18N);
ERROR_MESSAGE_INVALID_2FA,
ERROR_MESSAGE_INVALID_2FA_I18N,
);
} }
// Create full token // Create full token
+51 -10
View File
@@ -3,6 +3,7 @@ import _ from "lodash";
import errs from "../lib/error.js"; import errs from "../lib/error.js";
import utils from "../lib/utils.js"; import utils from "../lib/utils.js";
import authModel from "../models/auth.js"; import authModel from "../models/auth.js";
import proxyHostModel from "../models/proxy_host.js";
import userModel from "../models/user.js"; import userModel from "../models/user.js";
import userPermissionModel from "../models/user_permission.js"; import userPermissionModel from "../models/user_permission.js";
import internalAuditLog from "./audit-log.js"; import internalAuditLog from "./audit-log.js";
@@ -257,11 +258,9 @@ const internalUser = {
}, },
deleteAll: async () => { deleteAll: async () => {
await userModel await userModel.query().patch({
.query() is_deleted: 1,
.patch({ });
is_deleted: 1,
});
}, },
/** /**
@@ -390,11 +389,21 @@ const internalUser = {
.andWhere("type", data.type) .andWhere("type", data.type)
.first() .first()
.then((existing_auth) => { .then((existing_auth) => {
// Stamped here rather than read off modified_on, because it is compared against a
// token's `iat` and the two only line up when the same clock writes both. The
// database clock is a different one: with the app on one timezone and the database
// on another, its timestamps come back hours away from where Node thinks it is.
const password_changed_at = Math.floor(Date.now() / 1000);
if (existing_auth) { if (existing_auth) {
// patch // patch
const meta = existing_auth.meta || {};
meta.password_changed_at = password_changed_at;
return authModel.query().where("user_id", user.id).andWhere("type", data.type).patch({ return authModel.query().where("user_id", user.id).andWhere("type", data.type).patch({
type: data.type, // This is required for the model to encrypt on save type: data.type, // This is required for the model to encrypt on save
secret: data.secret, secret: data.secret,
meta,
}); });
} }
// insert // insert
@@ -402,7 +411,7 @@ const internalUser = {
user_id: user.id, user_id: user.id,
type: data.type, type: data.type,
secret: data.secret, secret: data.secret,
meta: {}, meta: { password_changed_at },
}); });
}) })
.then(() => { .then(() => {
@@ -436,7 +445,7 @@ const internalUser = {
return internalUser.get(access, { id: data.id }); return internalUser.get(access, { id: data.id });
}) })
.then((user) => { .then((user) => {
if (user.id !== data.id) { if (Number(user.id) !== Number(data.id)) {
// Sanity check that something crazy hasn't happened // Sanity check that something crazy hasn't happened
throw new errs.InternalValidationError( throw new errs.InternalValidationError(
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`, `User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
@@ -446,21 +455,23 @@ const internalUser = {
return user; return user;
}) })
.then((user) => { .then((user) => {
const permData = _.omit(data, ["id"]);
// Get perms row, patch if it exists // Get perms row, patch if it exists
return userPermissionModel return userPermissionModel
.query() .query()
.where("user_id", user.id) .where("user_id", user.id)
.first() .first()
.then((existing_auth) => { .then(async (existing_auth) => {
permData.meta = await internalUser.normalisePermissionMeta(existing_auth, permData);
if (existing_auth) { if (existing_auth) {
// patch // patch
return userPermissionModel return userPermissionModel
.query() .query()
.where("user_id", user.id) .where("user_id", user.id)
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data)); .patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData));
} }
// insert // insert
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data)); return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
}) })
.then((permissions) => { .then((permissions) => {
// Add to Audit Log // Add to Audit Log
@@ -480,6 +491,36 @@ const internalUser = {
}); });
}, },
/**
* The permission meta to store. Proxy host grants (meta.proxy_host_ids) only apply while
* visibility is "user" and proxy hosts are not hidden, so they're cleared otherwise; kept,
* they would silently come back when proxy hosts are shown again. Ids of hosts that no
* longer exist are dropped. `permData` may be partial, so the stored values fill the gaps.
*
* @param {Object} [existing] the stored user_permission row
* @param {Object} permData incoming permission fields
* @returns {Promise<Object>}
*/
normalisePermissionMeta: async (existing, permData) => {
const effective = _.assign(
{ visibility: "user", proxy_hosts: "hidden" },
existing || {},
_.omit(permData, ["meta"]),
);
const meta = _.assign({}, existing?.meta || {}, permData.meta || {});
const requested = Array.isArray(meta.proxy_host_ids) ? _.uniq(meta.proxy_host_ids) : [];
if (effective.visibility !== "user" || effective.proxy_hosts === "hidden" || !requested.length) {
meta.proxy_host_ids = [];
return meta;
}
const rows = await proxyHostModel.query().select("id").where("is_deleted", 0).whereIn("id", requested);
const found = new Set(rows.map((r) => r.id));
meta.proxy_host_ids = requested.filter((id) => found.has(id));
return meta;
},
/** /**
* @param {Access} access * @param {Access} access
* @param {Object} data * @param {Object} data
+11 -11
View File
@@ -1,19 +1,19 @@
module.exports = { module.exports = {
development: { development: {
client: 'mysql2', client: "mysql2",
migrations: { migrations: {
tableName: 'migrations', tableName: "migrations",
stub: 'lib/migrate_template.js', stub: "lib/migrate_template.js",
directory: 'migrations' directory: "migrations",
} },
}, },
production: { production: {
client: 'mysql2', client: "mysql2",
migrations: { migrations: {
tableName: 'migrations', tableName: "migrations",
stub: 'lib/migrate_template.js', stub: "lib/migrate_template.js",
directory: 'migrations' directory: "migrations",
} },
} },
}; };
+34 -8
View File
@@ -12,7 +12,8 @@ import { fileURLToPath } from "node:url";
import Ajv from "ajv/dist/2020.js"; import Ajv from "ajv/dist/2020.js";
import _ from "lodash"; import _ from "lodash";
import { access as logger } from "../logger.js"; import { access as logger } from "../logger.js";
import proxyHostModel from "../models/proxy_host.js"; import authModel from "../models/auth.js";
import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
import TokenModel from "../models/token.js"; import TokenModel from "../models/token.js";
import userModel from "../models/user.js"; import userModel from "../models/user.js";
import permsSchema from "./access/permissions.json" with { type: "json" }; import permsSchema from "./access/permissions.json" with { type: "json" };
@@ -80,6 +81,22 @@ export default function (tokenString) {
if (!ok) { if (!ok) {
throw new errs.AuthError("Invalid token scope for User"); throw new errs.AuthError("Invalid token scope for User");
} }
// A token issued before the password was last changed is no longer valid: taking an account
// back from whoever has the old password has to end the sessions that password opened.
const auth = await authModel
.query()
.where("user_id", "=", user.id)
.where("type", "=", "password")
.first();
// Both sides come from the same clock and in the same unit, whole seconds since
// the epoch: `setPassword` stamps the marker and `jsonwebtoken` stamps `iat`.
const changedAt = auth?.meta?.password_changed_at;
if (changedAt && typeof tokenData.iat === "number" && tokenData.iat < changedAt) {
throw new errs.TokenRevokedError("Token was issued before the password was changed");
}
initialised = true; initialised = true;
userRoles = user.roles; userRoles = user.roles;
permissions = user.permissions; permissions = user.permissions;
@@ -119,13 +136,10 @@ export default function (tokenString) {
// Proxy Hosts // Proxy Hosts
case "proxy_hosts": { case "proxy_hosts": {
const query = proxyHostModel const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
.query()
.select("id")
.andWhere("is_deleted", 0);
if (permissions.visibility === "user") { if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId); scopeToOwnerOrAllowed(query, tokenUserId, permissions.meta);
} }
const rows = await query; const rows = await query;
@@ -199,6 +213,11 @@ export default function (tokenString) {
return { return {
token: Token, token: Token,
getPermissions: () => permissions,
getRoles: () => userRoles,
hasRole: (role) => userRoles.includes(role),
isAdmin: () => userRoles.includes("admin") || Token.hasScope("admin"),
/** /**
* *
* @param {Boolean} [allowInternal] * @param {Boolean} [allowInternal]
@@ -206,7 +225,8 @@ export default function (tokenString) {
*/ */
load: async (allowInternal) => { load: async (allowInternal) => {
if (tokenString) { if (tokenString) {
return await Token.load(tokenString); await this.init();
return tokenData;
} }
allowInternalAccess = allowInternal; allowInternalAccess = allowInternal;
return allowInternal || null; return allowInternal || null;
@@ -241,6 +261,7 @@ export default function (tokenString) {
permission_streams: permissions.streams, permission_streams: permissions.streams,
permission_access_lists: permissions.access_lists, permission_access_lists: permissions.access_lists,
permission_certificates: permissions.certificates, permission_certificates: permissions.certificates,
permission_meta: permissions.meta,
}, },
}; };
@@ -271,7 +292,12 @@ export default function (tokenString) {
err.permission = permission; err.permission = permission;
err.permission_data = data; err.permission_data = data;
logger.error(permission, data, err.message); logger.error(permission, data, err.message);
throw errs.PermissionError("Permission Denied", err); // A revoked token is not a permission problem, and the client can tell: the frontend
// clears the session on a 401 and on nothing else.
if (err instanceof errs.TokenRevokedError) {
throw err;
}
throw new errs.PermissionError("Permission Denied", err);
} }
}, },
}; };
+7
View File
@@ -0,0 +1,7 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
+37 -17
View File
@@ -2,13 +2,13 @@ import fs from "node:fs";
import NodeRSA from "node-rsa"; import NodeRSA from "node-rsa";
import { global as logger } from "../logger.js"; import { global as logger } from "../logger.js";
const keysFile = '/data/keys.json'; const keysFile = "/data/keys.json";
const mysqlEngine = 'mysql2'; const mysqlEngine = "mysql2";
const postgresEngine = 'pg'; const postgresEngine = "pg";
const sqliteClientName = 'better-sqlite3'; const sqliteClientName = "better-sqlite3";
// Not used for new setups anymore but may exist in legacy setups // Not used for new setups anymore but may exist in legacy setups
const legacySqliteClientName = 'sqlite3'; const legacySqliteClientName = "sqlite3";
let instance = null; let instance = null;
@@ -40,14 +40,20 @@ const configure = () => {
} }
} }
const toBool = (v) => /^(1|true|yes|on)$/i.test((v || '').trim()); const toBool = (v) => /^(1|true|yes|on)$/i.test((v || "").trim());
const envMysqlHost = process.env.DB_MYSQL_HOST || null; const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null; const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null; const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL); const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized = process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined ? true : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED); const envMysqlSSLRejectUnauthorized =
const envMysqlSSLVerifyIdentity = process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined ? true : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY); process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity =
process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
if (envMysqlHost && envMysqlUser && envMysqlName) { if (envMysqlHost && envMysqlUser && envMysqlName) {
// we have enough mysql creds to go with mysql // we have enough mysql creds to go with mysql
logger.info("Using MySQL configuration"); logger.info("Using MySQL configuration");
@@ -58,8 +64,10 @@ const configure = () => {
port: process.env.DB_MYSQL_PORT || 3306, port: process.env.DB_MYSQL_PORT || 3306,
user: envMysqlUser, user: envMysqlUser,
password: process.env.DB_MYSQL_PASSWORD, password: process.env.DB_MYSQL_PASSWORD,
name: envMysqlName, name: envMysqlName,
ssl: envMysqlSSL ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } : false, ssl: envMysqlSSL
? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity }
: false,
}, },
keys: getKeys(), keys: getKeys(),
}; };
@@ -137,7 +145,7 @@ const generateKeys = () => {
// Write keys config // Write keys config
try { try {
fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2)); fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2), { mode: 0o600 });
} catch (err) { } catch (err) {
logger.error(`Could not write JWT key pair to config file: ${keysFile}: ${err.message}`); logger.error(`Could not write JWT key pair to config file: ${keysFile}: ${err.message}`);
process.exit(1); process.exit(1);
@@ -222,7 +230,7 @@ const isDebugMode = () => !!process.env.DEBUG;
* *
* @returns {boolean} * @returns {boolean}
*/ */
const isCI = () => process.env.CI === 'true' && process.env.DEBUG === 'true'; const isCI = () => process.env.CI === "true" && process.env.DEBUG === "true";
/** /**
* Returns a public key * Returns a public key
@@ -259,4 +267,16 @@ const useLetsencryptServer = () => {
return null; return null;
}; };
export { isCI, configHas, configGet, isSqlite, isMysql, isPostgres, isDebugMode, getPrivateKey, getPublicKey, useLetsencryptStaging, useLetsencryptServer }; export {
isCI,
configHas,
configGet,
isSqlite,
isMysql,
isPostgres,
isDebugMode,
getPrivateKey,
getPublicKey,
useLetsencryptStaging,
useLetsencryptServer,
};
+9
View File
@@ -22,6 +22,15 @@ const errs = {
this.status = 404; this.status = 404;
}, },
TokenRevokedError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.public = true;
this.status = 401;
},
AuthError: function (message, messageI18n, previous) { AuthError: function (message, messageI18n, previous) {
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name; this.name = this.constructor.name;
+1 -1
View File
@@ -1,4 +1,4 @@
import _ from "lodash"; import _ from "lodash";
export default (default_sort, default_offset, default_limit, max_limit) => { export default (default_sort, default_offset, default_limit, max_limit) => {
/** /**
+155
View File
@@ -0,0 +1,155 @@
import crypto from "node:crypto";
import { isCI } from "../config.js";
/**
* In-memory fixed window rate limiter for authentication endpoints
*/
// Keys longer than this are hashed, so a client can't make the store hold arbitrarily large
// strings (keys can include request body values such as the login identity).
const MAX_KEY_LENGTH = 128;
// Entries per limiter. A flood of unique keys sweeps expired entries, then evicts the oldest,
// instead of growing the store until the periodic cleanup runs.
const MAX_STORE_SIZE = 50000;
// One store per limiter, so separate endpoints never share buckets
const stores = new Set();
// Clean up old entries every 2 minutes
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const store of stores) {
for (const [key, record] of store.entries()) {
if (now - record.startTime > record.windowMs * 2) {
store.delete(key);
}
}
}
}, 120000);
cleanupTimer.unref();
// Generated at container start (prepare/45-admin-port.sh) and sent by the admin nginx as
// X-NPM-Peer-Secret alongside X-NPM-Peer-Addr. Unset (development, or a mounted production.conf)
// means the header is never trusted.
const peerSecret = Buffer.from(process.env.NPM_PEER_SECRET || "");
const hasPeerSecret = (value) => {
if (!peerSecret.length || typeof value !== "string") {
return false;
}
const given = Buffer.from(value);
return given.length === peerSecret.length && crypto.timingSafeEqual(given, peerSecret);
};
/**
* The address to rate limit on: the TCP peer of the admin nginx, which it passes as
* X-NPM-Peer-Addr ($realip_remote_addr). That header is only honoured from a loopback socket
* carrying the startup secret, so it can only have come from the admin nginx: a proxy host or
* stream forwarding to the backend port is also a loopback peer, but can't know the secret.
* The realip-resolved client address is deliberately not used: nginx trusts X-Real-IP from
* private ranges, so a client on the LAN or Docker network could pick a new address, and a fresh
* bucket, on every request. Behind another proxy (including an NPM proxy host in front of the
* admin UI) this resolves to that proxy's address.
*
* @param {Object} req
* @returns {String}
*/
export const getClientIp = (req) => {
const socketAddr = req.socket?.remoteAddress || "unknown";
const isLoopback = socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1";
const trusted = isLoopback && hasPeerSecret(req.headers["x-npm-peer-secret"]);
const peerAddr = trusted ? req.headers["x-npm-peer-addr"]?.trim() : null;
return peerAddr || socketAddr;
};
const makeRoom = (store, now) => {
for (const [k, r] of store.entries()) {
if (now - r.startTime > r.windowMs) {
store.delete(k);
}
}
// Maps iterate in insertion order, so this drops the oldest windows first
for (const k of store.keys()) {
if (store.size < MAX_STORE_SIZE) {
break;
}
store.delete(k);
}
};
const boundedKey = (key) =>
key.length > MAX_KEY_LENGTH ? `sha256:${crypto.createHash("sha256").update(key).digest("hex")}` : key;
/**
* @param {Object} [options]
* @param {Number} [options.windowMs]
* @param {Number} [options.max]
* @param {String} [options.message]
* @param {Boolean} [options.skipSuccessfulRequests]
* @param {Function} [options.keyGenerator] (req, clientIp) => String. Defaults to the client IP.
* Including the target account in the key stops every
* user being locked out when many clients share an IP
* (e.g. the admin UI behind another reverse proxy).
*/
export default function createRateLimiter(options = {}) {
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
const max = options.max || 10; // 10 requests default
const message = options.message || "Too many attempts from this IP, please try again later.";
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;
const keyGenerator = options.keyGenerator || ((_req, clientIp) => clientIp);
const store = new Map();
stores.add(store);
return (req, res, next) => {
// Bypass rate limiting in CI, test environments, or when explicitly disabled
if (
isCI() ||
process.env.CI === "true" ||
process.env.NODE_ENV === "test" ||
process.env.DISABLE_RATE_LIMIT === "true"
) {
return next();
}
const key = boundedKey(String(keyGenerator(req, getClientIp(req))));
const now = Date.now();
let record = store.get(key);
if (!record || now - record.startTime > windowMs) {
record = {
count: 0,
startTime: now,
windowMs,
};
if (!store.has(key) && store.size >= MAX_STORE_SIZE) {
makeRoom(store, now);
}
store.set(key, record);
}
record.count++;
if (skipSuccessfulRequests) {
const currentRecord = record;
res.on("finish", () => {
if (res.statusCode < 400 && currentRecord.count > 0) {
currentRecord.count--;
}
});
}
if (record.count > max) {
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
return res.status(429).json({
error: {
code: 429,
message,
},
});
}
next();
};
}
+2 -2
View File
@@ -1,6 +1,6 @@
export default (req, res, next) => { export default (req, res, next) => {
if (req.params.user_id === 'me' && res.locals.access) { if (req.params.user_id === "me" && res.locals.access) {
req.params.user_id = res.locals.access.token.get('attrs').id; req.params.user_id = res.locals.access.token.get("attrs").id;
} else { } else {
req.params.user_id = Number.parseInt(req.params.user_id, 10); req.params.user_id = Number.parseInt(req.params.user_id, 10);
} }
+134
View File
@@ -0,0 +1,134 @@
import net from "node:net";
import errs from "./error.js";
/**
* Rules for user-supplied values that the templates write into nginx config as-is.
*
* Anything that ends or changes an nginx token (whitespace, ";", "{", "}", quotes, "#" which
* starts a comment, and "\" which escapes the next character) would let the value add its own
* directives, which only admins may do (advanced_config). The API schema rejects these values
* with the same patterns (a test keeps the two in step). generateConfig() checks stored values
* again, so rows saved before these rules can't add directives either. That check is only about
* nginx structure, not format: a stored value in a format the API no longer accepts, but which
* can't add directives, still renders.
*
* Patterns are strings so they can be compared with the JSON schema; compile them with the "u"
* flag, as ajv does.
*/
// One character that can't end or change an unquoted nginx token
const TOKEN_CHAR = String.raw`[^\s\p{Cc};{}"'#\\]`;
// Host part of a forward host: also no "$" (no variables in the upstream address) and no "/"
const HOST_CHARS = String.raw`[^\s\p{Cc};{}"'#\\$/]+`;
// The existing domain name rule ("a.b.c", no "*", no empty labels), written so it can't
// backtrack exponentially
const DOMAIN = String.raw`[^.*]+(?:\.[^.*]+)*\.?[^.]`;
export const patterns = {
// server_name entries. The old character blacklist plus whitespace and control characters.
domainName: "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$",
// proxy_host forward_host and location forward_host: a host, optionally followed by a path
// (locations split "host/path" at render time)
forwardHost: String.raw`^${HOST_CHARS}(?:/${TOKEN_CHAR}*)?$`,
// location forward_path, appended straight after the port
forwardPath: String.raw`^${TOKEN_CHAR}*$`,
// location path: an optional modifier, then one token, either unquoted (a "\" must escape a
// visible character, so it can't swallow the space before "{") or double quoted. A modifier
// on its own isn't a location.
locationPath: String.raw`^(?!(?:=|~\*?|\^~)$)(?:(?:=|~\*?|\^~) +)?(?:"(?:[^"\\\p{Cc}]|\\[^\p{Cc}])+"|(?:[^\s\p{Cc};{}"'#\\]|\\[^\s\p{Cc}])+)$`,
// redirection forward_domain_name, e.g. "example.com:8443/path"
redirectDomain: String.raw`^(?=${TOKEN_CHAR}+$)${DOMAIN}$`,
// stream forwarding_host when it is a domain name (IPs are matched separately)
streamDomain: String.raw`^(?=${HOST_CHARS}$)${DOMAIN}$`,
// access list client address: IPv6 with an optional prefix. No zone id ("%eth0"): nginx's
// allow/deny rejects them, and the old "%.+" let anything through, including "; allow all"
clientIpv6: String.raw`^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$`,
// htpasswd user name: a ":" or a line break would corrupt the file
accessUsername: String.raw`^[^:\p{Cc}]+$`,
};
const re = Object.fromEntries(Object.entries(patterns).map(([k, v]) => [k, new RegExp(v, "u")]));
const unsafeToken = /[\s\p{Cc};{}"'#\\]/u;
const ipv4WithPrefix = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/;
export const isValid = (name, value) => typeof value === "string" && re[name].test(value);
export const isValidClientAddress = (value) =>
typeof value === "string" && (value === "all" || ipv4WithPrefix.test(value) || re.clientIpv6.test(value));
export const isValidStreamHost = (value) =>
typeof value === "string" && (net.isIP(value) !== 0 || re.streamDomain.test(value));
/**
* Thrown when a stored value can't be written into nginx config safely
*
* @param {String} message
*/
export const unsafeValueError = (message) => {
const err = new errs.ConfigurationError(message);
err.unsafeNginxValue = true;
return err;
};
const describe = (value) => JSON.stringify(String(value)).slice(0, 80);
/**
* @param {String} label e.g. "Proxy Host #3"
* @param {Object} [accessList]
* @throws {ConfigurationError}
*/
export const assertAccessListRenderable = (label, accessList) => {
for (const client of accessList?.clients || []) {
// "address" is rendered unquoted ("allow <address>;")
if (typeof client.address !== "string" || unsafeToken.test(client.address)) {
throw unsafeValueError(`${label}: access list client address ${describe(client.address)} is not allowed`);
}
}
};
// Can be written into config without ending or changing a token
export const isTokenSafe = (value) => typeof value === "string" && value.length > 0 && !unsafeToken.test(value);
/**
* Checks every user-supplied value the host's template renders. Call before rendering.
*
* @param {String} hostType proxy_host, redirection_host, dead_host, stream or default
* @param {Object} host with access_list loaded, for proxy hosts
* @throws {ConfigurationError}
*/
export const assertHostRenderable = (hostType, host) => {
const label = `${hostType.replace(/_/g, " ")} #${host.id}`;
const check = (ok, what, value) => {
if (!ok) {
throw unsafeValueError(`${label}: ${what} ${describe(value)} is not allowed in nginx config`);
}
};
if (hostType !== "stream" && hostType !== "default") {
for (const name of host.domain_names || []) {
check(isTokenSafe(name), "domain name", name);
}
}
if (hostType === "proxy_host") {
check(isTokenSafe(host.forward_host), "forward host", host.forward_host);
assertAccessListRenderable(label, host.access_list);
for (const loc of host.locations || []) {
// The path is several tokens (modifier, then a quoted or unquoted URI): its rule is the
// structural one
check(isValid("locationPath", loc.path), "location path", loc.path);
check(isTokenSafe(loc.forward_host), "location forward host", loc.forward_host);
if (loc.forward_path) {
check(isTokenSafe(loc.forward_path), "location forward path", loc.forward_path);
}
}
}
if (hostType === "redirection_host") {
check(isTokenSafe(host.forward_domain_name), "forward domain", host.forward_domain_name);
}
if (hostType === "stream") {
check(isTokenSafe(host.forwarding_host), "forwarding host", host.forwarding_host);
}
};
+11 -5
View File
@@ -1,3 +1,4 @@
import net from "node:net";
import Ajv from "ajv/dist/2020.js"; import Ajv from "ajv/dist/2020.js";
import errs from "../error.js"; import errs from "../error.js";
@@ -9,6 +10,8 @@ const ajv = new Ajv({
coerceTypes: true, coerceTypes: true,
}); });
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
/** /**
* @param {Object} schema * @param {Object} schema
* @param {Object} payload * @param {Object} payload
@@ -24,21 +27,24 @@ const apiValidator = async (schema, payload /*, description*/) => {
throw new errs.ValidationError("Payload is undefined"); throw new errs.ValidationError("Payload is undefined");
} }
const validate = ajv.compile(schema); const validate = ajv.compile(schema);
const valid = validate(payload); const valid = validate(payload);
if (valid && !validate.errors) { if (valid && !validate.errors) {
return payload; return payload;
} }
// Keep ajv's wording, which clients may match on, but not the regex it quotes: some are long
// and mean nothing to a user
for (const e of validate.errors || []) {
if (e.keyword === "pattern") {
e.message = "must match pattern";
}
}
const message = ajv.errorsText(validate.errors); const message = ajv.errorsText(validate.errors);
const err = new errs.ValidationError(message); const err = new errs.ValidationError(message);
err.debug = {validationErrors: validate.errors, payload}; err.debug = { validationErrors: validate.errors, payload };
throw err; throw err;
}; };
+4 -1
View File
@@ -1,4 +1,5 @@
import Ajv from 'ajv/dist/2020.js'; import net from "node:net";
import Ajv from "ajv/dist/2020.js";
import _ from "lodash"; import _ from "lodash";
import commonDefinitions from "../../schema/common.json" with { type: "json" }; import commonDefinitions from "../../schema/common.json" with { type: "json" };
import errs from "../error.js"; import errs from "../error.js";
@@ -14,6 +15,8 @@ const ajv = new Ajv({
schemas: [commonDefinitions], schemas: [commonDefinitions],
}); });
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
/** /**
* *
* @param {Object} schema * @param {Object} schema
+111 -11
View File
@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import signale from "signale"; import signale from "signale";
import { isDebugMode } from "./lib/config.js"; import { isDebugMode } from "./lib/config.js";
@@ -5,17 +7,115 @@ const opts = {
logLevel: "info", logLevel: "info",
}; };
const global = new signale.Signale({ scope: "Global ", ...opts }); // Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc).
const migrate = new signale.Signale({ scope: "Migrate ", ...opts }); // Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...)
const express = new signale.Signale({ scope: "Express ", ...opts }); // are left console-only since they carry no diagnostic value worth persisting.
const access = new signale.Signale({ scope: "Access ", ...opts }); const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"];
const nginx = new signale.Signale({ scope: "Nginx ", ...opts });
const ssl = new signale.Signale({ scope: "SSL ", ...opts }); const LOG_FILE = "/data/logs/backend.log";
const certbot = new signale.Signale({ scope: "Certbot ", ...opts }); // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk
const importer = new signale.Signale({ scope: "Importer ", ...opts }); const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
const setup = new signale.Signale({ scope: "Setup ", ...opts });
const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); let fileStream = null;
const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); let lastOpenAttempt = 0;
// If the file can't be opened (eg. running outside the standard Docker image, or in
// CI without /data), don't retry on every single log call - but do retry periodically
// so a transient issue (disk full, permissions fixed later) recovers without a restart.
const REOPEN_COOLDOWN_MS = 30 * 1000;
/**
* Lazily opens the backend log file for appending. If the directory isn't writable,
* file logging is silently disabled and console logging continues unaffected.
*
* @returns {import('node:fs').WriteStream|null}
*/
const getFileStream = () => {
if (fileStream) {
return fileStream;
}
const now = Date.now();
if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) {
return null;
}
lastOpenAttempt = now;
try {
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(LOG_FILE, { flags: "a" });
stream.on("error", () => {
fileStream = null;
});
fileStream = stream;
} catch (_err) {
fileStream = null;
}
return fileStream;
};
/**
* Formats and appends a single log line to the backend log file.
* Never throws - a failure here must never take down the application.
*
* @param {String} level
* @param {String} scope
* @param {Array} args
*/
const writeToFile = (level, scope, args) => {
const stream = getFileStream();
if (!stream) {
return;
}
const message = args
.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return arg.stack || arg.message;
try {
return JSON.stringify(arg);
} catch (_err) {
return String(arg);
}
})
.join(" ")
.replace(ANSI_PATTERN, "");
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`;
stream.write(line);
};
/**
* Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also
* appended to the backend log file, in addition to its normal console output.
*
* @param {Signale} instance
* @param {String} scope
* @returns {Signale}
*/
const withFileSink = (instance, scope) => {
for (const method of PERSISTED_METHODS) {
const original = instance[method].bind(instance);
instance[method] = (...args) => {
writeToFile(method, scope, args);
return original(...args);
};
}
return instance;
};
const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope);
const global = createLogger("Global ");
const migrate = createLogger("Migrate ");
const express = createLogger("Express ");
const access = createLogger("Access ");
const nginx = createLogger("Nginx ");
const ssl = createLogger("SSL ");
const certbot = createLogger("Certbot ");
const importer = createLogger("Importer ");
const setup = createLogger("Setup ");
const ipRanges = createLogger("IP Ranges");
const remoteVersion = createLogger("Remote Version");
const debug = (logger, ...args) => { const debug = (logger, ...args) => {
if (isDebugMode()) { if (isDebugMode()) {
@@ -13,13 +13,14 @@ const migrateName = "settings";
const up = (knex) => { const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`); logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema.createTable('setting', (table) => { return knex.schema
table.string('id').notNull().primary(); .createTable("setting", (table) => {
table.string('name', 100).notNull(); table.string("id").notNull().primary();
table.string('description', 255).notNull(); table.string("name", 100).notNull();
table.string('value', 255).notNull(); table.string("description", 255).notNull();
table.json('meta').notNull(); table.string("value", 255).notNull();
}) table.json("meta").notNull();
})
.then(() => { .then(() => {
logger.info(`[${migrateName}] setting Table created`); logger.info(`[${migrateName}] setting Table created`);
}); });
@@ -17,9 +17,7 @@ const up = (knex) => {
.table("redirection_host", async (table) => { .table("redirection_host", async (table) => {
// change the column default from $scheme to auto // change the column default from $scheme to auto
await table.string("forward_scheme").notNull().defaultTo("auto").alter(); await table.string("forward_scheme").notNull().defaultTo("auto").alter();
await knex('redirection_host') await knex("redirection_host").where("forward_scheme", "$scheme").update({ forward_scheme: "auto" });
.where('forward_scheme', '$scheme')
.update({ forward_scheme: 'auto' });
}) })
.then(() => { .then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`); logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -38,9 +36,7 @@ const down = (knex) => {
return knex.schema return knex.schema
.table("redirection_host", async (table) => { .table("redirection_host", async (table) => {
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter(); await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
await knex('redirection_host') await knex("redirection_host").where("forward_scheme", "auto").update({ forward_scheme: "$scheme" });
.where('forward_scheme', 'auto')
.update({ forward_scheme: '$scheme' });
}) })
.then(() => { .then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`); logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -11,15 +11,15 @@ const migrateName = "trust_forwarded_proto";
* @returns {Promise} * @returns {Promise}
*/ */
const up = (knex) => { const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`); logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema return knex.schema
.alterTable('proxy_host', (table) => { .alterTable("proxy_host", (table) => {
table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0); table.tinyint("trust_forwarded_proto").notNullable().defaultTo(0);
}) })
.then(() => { .then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`); logger.info(`[${migrateName}] proxy_host Table altered`);
}); });
}; };
/** /**
@@ -29,15 +29,15 @@ const up = (knex) => {
* @returns {Promise} * @returns {Promise}
*/ */
const down = (knex) => { const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`); logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema return knex.schema
.alterTable('proxy_host', (table) => { .alterTable("proxy_host", (table) => {
table.dropColumn('trust_forwarded_proto'); table.dropColumn("trust_forwarded_proto");
}) })
.then(() => { .then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`); logger.info(`[${migrateName}] proxy_host Table altered`);
}); });
}; };
export { up, down }; export { up, down };
@@ -0,0 +1,41 @@
import { migrate as logger } from "../logger.js";
const migrateName = "user_permission_meta";
/**
* Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema
.alterTable("user_permission", (table) => {
table.json("meta").nullable();
})
.then(() => {
logger.info(`[${migrateName}] user_permission Table altered`);
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema
.alterTable("user_permission", (table) => {
table.dropColumn("meta");
})
.then(() => {
logger.info(`[${migrateName}] user_permission Table altered`);
});
};
export { up, down };
+4
View File
@@ -31,6 +31,10 @@ class AccessList extends Model {
$parseDatabaseJson(json) { $parseDatabaseJson(json) {
const thisJson = super.$parseDatabaseJson(json); const thisJson = super.$parseDatabaseJson(json);
// Postgres returns COUNT() as a string
if (typeof thisJson.proxy_host_count === "string") {
thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
}
return convertIntFieldsToBool(thisJson, boolFields); return convertIntFieldsToBool(thisJson, boolFields);
} }
+19 -1
View File
@@ -1,6 +1,7 @@
// Objection Docs: // Objection Docs:
// http://vincit.github.io/objection.js/ // http://vincit.github.io/objection.js/
import crypto from "node:crypto";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { Model } from "objection"; import { Model } from "objection";
import db from "../db.js"; import db from "../db.js";
@@ -11,10 +12,15 @@ import User from "./user.js";
Model.knex(db()); Model.knex(db());
const boolFields = ["is_deleted"]; const boolFields = ["is_deleted"];
const saltRounds = 13;
// A hash of a random value, for a login that names an account with no password to check it against. Hashed
// with the same cost as real passwords, and made at startup so the first such login takes no longer either.
const dummyHash = bcrypt.hash(crypto.randomBytes(32).toString("hex"), saltRounds);
function encryptPassword() { function encryptPassword() {
if (this.type === "password" && this.secret) { if (this.type === "password" && this.secret) {
return bcrypt.hash(this.secret, 13).then((hash) => { return bcrypt.hash(this.secret, saltRounds).then((hash) => {
this.secret = hash; this.secret = hash;
}); });
} }
@@ -60,6 +66,18 @@ class Auth extends Model {
return bcrypt.compare(password, this.secret); return bcrypt.compare(password, this.secret);
} }
/**
* Take as long as verifyPassword does, then fail. bcrypt is nearly all of the time a login takes, so a login
* that skipped it for an unknown account would answer far sooner, and say which accounts exist.
*
* @param {String} password
* @returns {Promise<false>}
*/
static async verifyDummyPassword(password) {
await bcrypt.compare(password, await dummyHash);
return false;
}
static get name() { static get name() {
return "Auth"; return "Auth";
} }
+19
View File
@@ -124,4 +124,23 @@ class ProxyHost extends Model {
} }
} }
/**
* Restricts a proxy host query to hosts the user owns or has been granted by id
* (user permission meta.proxy_host_ids).
*
* @param {Object} query objection query on proxy_host
* @param {Number} userId
* @param {Object} [permissionMeta] the user's permission meta
* @returns {Object} the query
*/
export const scopeToOwnerOrAllowed = (query, userId, permissionMeta) => {
const allowedIds = permissionMeta?.proxy_host_ids || [];
return query.andWhere((builder) => {
builder.where("owner_user_id", userId);
if (allowedIds.length > 0) {
builder.orWhereIn("id", allowedIds);
}
});
};
export default ProxyHost; export default ProxyHost;
+8 -8
View File
@@ -7,23 +7,23 @@ import db from "../db.js";
Model.knex(db()); Model.knex(db());
class Setting extends Model { class Setting extends Model {
$beforeInsert () { $beforeInsert() {
// Default for meta // Default for meta
if (typeof this.meta === 'undefined') { if (typeof this.meta === "undefined") {
this.meta = {}; this.meta = {};
} }
} }
static get name () { static get name() {
return 'Setting'; return "Setting";
} }
static get tableName () { static get tableName() {
return 'setting'; return "setting";
} }
static get jsonAttributes () { static get jsonAttributes() {
return ['meta']; return ["meta"];
} }
} }
+11 -7
View File
@@ -8,21 +8,25 @@ import now from "./now_helper.js";
Model.knex(db()); Model.knex(db());
class UserPermission extends Model { class UserPermission extends Model {
$beforeInsert () { $beforeInsert() {
this.created_on = now(); this.created_on = now();
this.modified_on = now(); this.modified_on = now();
} }
$beforeUpdate () { $beforeUpdate() {
this.modified_on = now(); this.modified_on = now();
} }
static get name () { static get name() {
return 'UserPermission'; return "UserPermission";
} }
static get tableName () { static get tableName() {
return 'user_permission'; return "user_permission";
}
static get jsonAttributes() {
return ["meta"];
} }
} }
+19 -18
View File
@@ -8,42 +8,43 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "biome lint", "lint": "biome lint",
"test": "node --test test/",
"prettier": "biome format --write .", "prettier": "biome format --write .",
"validate-schema": "node validate-schema.js", "validate-schema": "node validate-schema.js",
"regenerate-config": "node scripts/regenerate-config" "regenerate-config": "node scripts/regenerate-config"
}, },
"dependencies": { "dependencies": {
"@apidevtools/json-schema-ref-parser": "^15.3.5", "@apidevtools/json-schema-ref-parser": "^16.0.6",
"ajv": "^8.20.0", "ajv": "^8.20.0",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"batchflow": "^0.4.0", "batchflow": "^0.4.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^13.0.3",
"body-parser": "^2.2.2", "body-parser": "^2.4.0",
"chalk": "5.6.2", "chalk": "6.0.1",
"compression": "^1.8.1", "compression": "^1.8.2",
"express": "^5.2.1", "express": "^5.3.0",
"express-fileupload": "^1.5.2", "express-fileupload": "^1.6.0",
"gravatar": "^1.8.2", "gravatar": "^1.8.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"knex": "3.2.10", "knex": "3.3.0",
"liquidjs": "10.27.0", "liquidjs": "10.30.1",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"moment": "^2.30.1", "moment": "^2.31.0",
"mysql2": "^3.22.3", "mysql2": "^3.24.6",
"node-rsa": "^1.1.1", "node-rsa": "^2.0.0",
"objection": "3.1.5", "objection": "4.4.3",
"otplib": "^13.4.0", "otplib": "^13.6.0",
"path": "^0.12.7", "path": "^0.12.7",
"pg": "^8.21.0", "pg": "^8.23.1",
"proxy-agent": "^8.0.1", "proxy-agent": "^8.0.2",
"signale": "1.4.0", "signale": "1.4.0",
"sqlite3": "^6.0.1", "sqlite3": "^6.0.1",
"temp-write": "^6.0.1" "temp-write": "^6.0.1"
}, },
"devDependencies": { "devDependencies": {
"@apidevtools/swagger-parser": "^12.1.0", "@apidevtools/swagger-parser": "^13.1.0",
"@biomejs/biome": "^2.4.15", "@biomejs/biome": "^2.6.6",
"nodemon": "^3.1.14" "nodemon": "^3.1.14"
}, },
"signale": { "signale": {
+1 -4
View File
@@ -86,10 +86,7 @@ router
}, },
{ {
event_id: req.params.event_id, event_id: req.params.event_id,
expand: expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
}, },
); );
+112
View File
@@ -0,0 +1,112 @@
import express from "express";
import internalLogViewer from "../internal/log-viewer.js";
import jwtdecode from "../lib/express/jwt-decode.js";
import validator from "../lib/validator/index.js";
import { debug, express as logger } from "../logger.js";
const router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true,
});
/**
* /api/logs/sources
*/
router
.route("/sources")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/logs/sources
*
* Lists the log sources available for the log viewer
*/
.get(async (req, res, next) => {
try {
const data = await internalLogViewer.listSources(res.locals.access);
res.status(200).send(data);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* /api/logs/tail
*/
router
.route("/tail")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/logs/tail
*
* Retrieve the last N lines of a log source
*/
.get(async (req, res, next) => {
try {
const data = await validator(
{
required: ["type"],
additionalProperties: false,
properties: {
type: {
type: "string",
enum: ["system", "letsencrypt", "host"],
},
host_type: {
anyOf: [
{ type: "null" },
{ type: "string", enum: ["proxy", "redirection", "dead", "stream"] },
],
},
host_id: {
anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }],
},
channel: {
anyOf: [{ type: "null" }, { type: "string", enum: ["access", "error"] }],
},
lines: {
anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: 1000 }],
},
level: {
anyOf: [
{ type: "null" },
{
type: "string",
enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"],
},
],
},
search: {
anyOf: [{ type: "null" }, { type: "string", minLength: 1, maxLength: 200 }],
},
},
},
{
type: req.query.type,
host_type: typeof req.query.host_type === "string" ? req.query.host_type : null,
host_id: typeof req.query.host_id !== "undefined" ? req.query.host_id : null,
channel: typeof req.query.channel === "string" ? req.query.channel : null,
lines: typeof req.query.lines !== "undefined" ? req.query.lines : null,
level: typeof req.query.level === "string" ? req.query.level : null,
search: typeof req.query.search === "string" ? req.query.search : null,
},
);
const result = await internalLogViewer.tail(res.locals.access, data);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;
+2
View File
@@ -6,6 +6,7 @@ import pjson from "../package.json" with { type: "json" };
import { isSetup } from "../setup.js"; import { isSetup } from "../setup.js";
import auditLogRoutes from "./audit-log.js"; import auditLogRoutes from "./audit-log.js";
import ciRoutes from "./ci.js"; import ciRoutes from "./ci.js";
import logsRoutes from "./logs.js";
import accessListsRoutes from "./nginx/access_lists.js"; import accessListsRoutes from "./nginx/access_lists.js";
import certificatesHostsRoutes from "./nginx/certificates.js"; import certificatesHostsRoutes from "./nginx/certificates.js";
import deadHostsRoutes from "./nginx/dead_hosts.js"; import deadHostsRoutes from "./nginx/dead_hosts.js";
@@ -50,6 +51,7 @@ router.use("/schema", schemaRoutes);
router.use("/tokens", tokensRoutes); router.use("/tokens", tokensRoutes);
router.use("/users", usersRoutes); router.use("/users", usersRoutes);
router.use("/audit-log", auditLogRoutes); router.use("/audit-log", auditLogRoutes);
router.use("/logs", logsRoutes);
router.use("/reports", reportsRoutes); router.use("/reports", reportsRoutes);
router.use("/settings", settingsRoutes); router.use("/settings", settingsRoutes);
router.use("/version", versionRoutes); router.use("/version", versionRoutes);
+8 -30
View File
@@ -44,18 +44,11 @@ router
}, },
}, },
{ {
expand: expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
query: typeof req.query.query === "string" ? req.query.query : null, query: typeof req.query.query === "string" ? req.query.query : null,
}, },
); );
const rows = await internalCertificate.getAll( const rows = await internalCertificate.getAll(res.locals.access, data.expand, data.query);
res.locals.access,
data.expand,
data.query,
);
res.status(200).send(rows); res.status(200).send(rows);
} catch (err) { } catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -70,15 +63,9 @@ router
*/ */
.post(async (req, res, next) => { .post(async (req, res, next) => {
try { try {
const payload = await apiValidator( const payload = await apiValidator(getValidationSchema("/nginx/certificates", "post"), req.body);
getValidationSchema("/nginx/certificates", "post"),
req.body,
);
req.setTimeout(900000); // 15 minutes timeout req.setTimeout(900000); // 15 minutes timeout
const result = await internalCertificate.create( const result = await internalCertificate.create(res.locals.access, payload);
res.locals.access,
payload,
);
res.status(201).send(result); res.status(201).send(result);
} catch (err) { } catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -139,16 +126,10 @@ router
*/ */
.post(async (req, res, next) => { .post(async (req, res, next) => {
try { try {
const payload = await apiValidator( const payload = await apiValidator(getValidationSchema("/nginx/certificates/test-http", "post"), req.body);
getValidationSchema("/nginx/certificates/test-http", "post"),
req.body,
);
req.setTimeout(60000); // 1 minute timeout req.setTimeout(60000); // 1 minute timeout
const result = await internalCertificate.testHttpsChallenge( const result = await internalCertificate.testHttpsChallenge(res.locals.access, payload);
res.locals.access,
payload,
);
res.status(200).send(result); res.status(200).send(result);
} catch (err) { } catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -180,7 +161,7 @@ router
} }
try { try {
const result = await internalCertificate.validate({ const result = await internalCertificate.validate(res.locals.access, {
files: req.files, files: req.files,
}); });
res.status(200).send(result); res.status(200).send(result);
@@ -224,10 +205,7 @@ router
}, },
{ {
certificate_id: req.params.certificate_id, certificate_id: req.params.certificate_id,
expand: expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
}, },
); );
const row = await internalCertificate.get(res.locals.access, { const row = await internalCertificate.get(res.locals.access, {
+4 -2
View File
@@ -194,9 +194,11 @@ router
/** /**
* POST /api/nginx/dead-hosts/123/disable * POST /api/nginx/dead-hosts/123/disable
*/ */
.post((req, res, next) => { .post(async (req, res, next) => {
try { try {
const result = internalDeadHost.disable(res.locals.access, { id: Number.parseInt(req.params.host_id, 10) }); const result = await internalDeadHost.disable(res.locals.access, {
id: Number.parseInt(req.params.host_id, 10),
});
res.status(200).send(result); res.status(200).send(result);
} catch (err) { } catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
+67
View File
@@ -1,4 +1,5 @@
import express from "express"; import express from "express";
import fs from "node:fs";
import internalProxyHost from "../../internal/proxy-host.js"; import internalProxyHost from "../../internal/proxy-host.js";
import jwtdecode from "../../lib/express/jwt-decode.js"; import jwtdecode from "../../lib/express/jwt-decode.js";
import apiValidator from "../../lib/validator/api.js"; import apiValidator from "../../lib/validator/api.js";
@@ -206,4 +207,70 @@ router
} }
}); });
/**
* Proxy-host logs
*
* /api/nginx/proxy-hosts/123/logs
*/
router
.route("/:host_id/logs")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/nginx/proxy-hosts/123/logs
*
* Retrieve logs for a specific proxy-host
*/
.get(async (req, res, next) => {
try {
const data = await validator(
{
required: ["host_id"],
additionalProperties: false,
properties: {
host_id: {
$ref: "common#/properties/id",
},
type: {
type: "string",
enum: ["access", "error"],
},
},
},
{
host_id: req.params.host_id,
type: req.query.type || "access",
},
);
const hostId = Number.parseInt(data.host_id, 10);
const logType = data.type === "error" ? "error" : "access";
const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`;
// Check access permission
await res.locals.access.can("proxy_hosts:get", hostId);
let logs = "";
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, { encoding: "utf8" });
const lines = content.split("\n");
// Return last 1000 lines to avoid huge payloads
const maxLines = 1000;
if (lines.length > maxLines) {
logs = lines.slice(-maxLines).join("\n");
} else {
logs = content;
}
}
res.status(200).send({ logs });
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router; export default router;
+48 -2
View File
@@ -1,10 +1,56 @@
import express from "express"; import express from "express";
import internalToken from "../internal/token.js"; import internalToken from "../internal/token.js";
import jwtdecode from "../lib/express/jwt-decode.js"; import jwtdecode from "../lib/express/jwt-decode.js";
import createRateLimiter from "../lib/express/rate-limit.js";
import apiValidator from "../lib/validator/api.js"; import apiValidator from "../lib/validator/api.js";
import { debug, express as logger } from "../logger.js"; import { debug, express as logger } from "../logger.js";
import { getValidationSchema } from "../schema/index.js"; import { getValidationSchema } from "../schema/index.js";
// Two limits apply to logins. Per IP + target account: when the admin UI sits behind another
// proxy every client can resolve to the same IP, and an IP-only bucket at this size would let
// anyone lock out every user with a handful of bad logins.
const tokenRateLimiter = createRateLimiter({
windowMs: 60 * 1000,
max: 10,
message: "Too many login attempts. Please try again in 1 minute.",
skipSuccessfulRequests: true,
keyGenerator: (req, clientIp) => {
const identity = typeof req.body?.identity === "string" ? req.body.identity.trim().toLowerCase() : "";
return `${clientIp}|${identity}`;
},
});
// And per IP across all accounts, so one address can't spray passwords over many accounts at
// 10 a minute each. Only failures count. Behind a shared proxy this cap is shared by everyone
// using it, which is why it's higher than the per-account one.
const tokenIpRateLimiter = createRateLimiter({
windowMs: 60 * 1000,
max: 30,
message: "Too many login attempts. Please try again in 1 minute.",
skipSuccessfulRequests: true,
});
// Keyed on the user the challenge belongs to, not the challenge token itself, so fetching
// fresh challenge tokens doesn't reset the code-guessing budget. The payload is read without
// verifying the signature; verify2FA does that. A forged token only affects buckets for the
// sender's own IP.
const challengeUserId = (token) => {
try {
const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
return String(payload?.attrs?.id ?? "");
} catch {
return "";
}
};
const twoFactorRateLimiter = createRateLimiter({
windowMs: 60 * 1000,
max: 10,
message: "Too many login attempts. Please try again in 1 minute.",
skipSuccessfulRequests: true,
keyGenerator: (req, clientIp) => `${clientIp}|${challengeUserId(req.body?.challenge_token)}`,
});
const router = express.Router({ const router = express.Router({
caseSensitive: true, caseSensitive: true,
strict: true, strict: true,
@@ -42,7 +88,7 @@ router
* *
* Create a new Token * Create a new Token
*/ */
.post(async (req, res, next) => { .post(tokenIpRateLimiter, tokenRateLimiter, async (req, res, next) => {
try { try {
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body); const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
const result = await internalToken.getTokenFromEmail(data); const result = await internalToken.getTokenFromEmail(data);
@@ -64,7 +110,7 @@ router
* *
* Verify 2FA code and get full token * Verify 2FA code and get full token
*/ */
.post(async (req, res, next) => { .post(twoFactorRateLimiter, async (req, res, next) => {
try { try {
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body); const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
const result = await internalToken.verify2FA(challenge_token, code); const result = await internalToken.verify2FA(challenge_token, code);
+11 -42
View File
@@ -48,18 +48,11 @@ router
}, },
}, },
{ {
expand: expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
query: typeof req.query.query === "string" ? req.query.query : null, query: typeof req.query.query === "string" ? req.query.query : null,
}, },
); );
const users = await internalUser.getAll( const users = await internalUser.getAll(res.locals.access, data.expand, data.query);
res.locals.access,
data.expand,
data.query,
);
res.status(200).send(users); res.status(200).send(users);
} catch (err) { } catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -95,10 +88,7 @@ router
} }
} }
const payload = await apiValidator( const payload = await apiValidator(getValidationSchema("/users", "post"), body);
getValidationSchema("/users", "post"),
body,
);
const user = await internalUser.create(res.locals.access, payload); const user = await internalUser.create(res.locals.access, payload);
res.status(201).send(user); res.status(201).send(user);
} catch (err) { } catch (err) {
@@ -169,20 +159,14 @@ router
}, },
{ {
user_id: req.params.user_id, user_id: req.params.user_id,
expand: expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
}, },
); );
const user = await internalUser.get(res.locals.access, { const user = await internalUser.get(res.locals.access, {
id: data.user_id, id: data.user_id,
expand: data.expand, expand: data.expand,
omit: internalUser.getUserOmisionsByAccess( omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id),
res.locals.access,
data.user_id,
),
}); });
res.status(200).send(user); res.status(200).send(user);
} catch (err) { } catch (err) {
@@ -198,10 +182,7 @@ router
*/ */
.put(async (req, res, next) => { .put(async (req, res, next) => {
try { try {
const payload = await apiValidator( const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body);
getValidationSchema("/users/{userID}", "put"),
req.body,
);
payload.id = req.params.user_id; payload.id = req.params.user_id;
const result = await internalUser.update(res.locals.access, payload); const result = await internalUser.update(res.locals.access, payload);
res.status(200).send(result); res.status(200).send(result);
@@ -248,10 +229,7 @@ router
*/ */
.put(async (req, res, next) => { .put(async (req, res, next) => {
try { try {
const payload = await apiValidator( const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body);
getValidationSchema("/users/{userID}/auth", "put"),
req.body,
);
payload.id = req.params.user_id; payload.id = req.params.user_id;
const result = await internalUser.setPassword(res.locals.access, payload); const result = await internalUser.setPassword(res.locals.access, payload);
res.status(200).send(result); res.status(200).send(result);
@@ -281,15 +259,9 @@ router
*/ */
.put(async (req, res, next) => { .put(async (req, res, next) => {
try { try {
const payload = await apiValidator( const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
getValidationSchema("/users/{userID}/permissions", "put"), payload.id = Number.parseInt(req.params.user_id, 10);
req.body, const result = await internalUser.setPermissions(res.locals.access, payload);
);
payload.id = req.params.user_id;
const result = await internalUser.setPermissions(
res.locals.access,
payload,
);
res.status(200).send(result); res.status(200).send(result);
} catch (err) { } catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -408,10 +380,7 @@ router
*/ */
.post(async (req, res, next) => { .post(async (req, res, next) => {
try { try {
const { code } = await apiValidator( const { code } = await apiValidator(getValidationSchema("/users/{userID}/2fa/enable", "post"), req.body);
getValidationSchema("/users/{userID}/2fa/enable", "post"),
req.body,
);
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code); const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
res.status(200).send(result); res.status(200).send(result);
} catch (err) { } catch (err) {
+4 -3
View File
@@ -84,7 +84,7 @@
"uniqueItems": true, "uniqueItems": true,
"items": { "items": {
"type": "string", "type": "string",
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
}, },
"example": ["example.com", "www.example.com"] "example": ["example.com", "www.example.com"]
}, },
@@ -147,7 +147,7 @@
}, },
{ {
"type": "string", "type": "string",
"pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$" "pattern": "^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$"
}, },
{ {
"type": "string", "type": "string",
@@ -164,7 +164,8 @@
"properties": { "properties": {
"username": { "username": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1,
"pattern": "^[^:\\p{Cc}]+$"
}, },
"password": { "password": {
"type": "string" "type": "string"
@@ -31,7 +31,7 @@
"uniqueItems": true, "uniqueItems": true,
"items": { "items": {
"type": "string", "type": "string",
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$" "pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
}, },
"example": ["example.com", "www.example.com"] "example": ["example.com", "www.example.com"]
}, },
@@ -0,0 +1,69 @@
{
"type": "object",
"description": "Available log sources for the log viewer",
"required": ["system", "letsencrypt", "hosts"],
"additionalProperties": false,
"properties": {
"system": {
"type": "object",
"properties": {
"label": {
"type": "string",
"example": "System"
}
}
},
"letsencrypt": {
"type": "object",
"properties": {
"label": {
"type": "string",
"example": "Let's Encrypt"
}
}
},
"hosts": {
"type": "object",
"required": ["proxy", "redirection", "dead", "stream"],
"additionalProperties": false,
"properties": {
"proxy": {
"$ref": "#/$defs/host-option-list"
},
"redirection": {
"$ref": "#/$defs/host-option-list"
},
"dead": {
"$ref": "#/$defs/host-option-list"
},
"stream": {
"$ref": "#/$defs/host-option-list"
}
},
"example": {
"proxy": [{ "id": 1, "label": "example.com" }],
"redirection": [],
"dead": [],
"stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
}
}
},
"$defs": {
"host-option-list": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "label"],
"properties": {
"id": {
"$ref": "../common.json#/properties/id"
},
"label": {
"type": "string",
"example": "example.com"
}
}
}
}
}
}
@@ -0,0 +1,30 @@
{
"type": "object",
"description": "The last N lines of a log source",
"required": ["lines", "size", "truncated", "exists"],
"additionalProperties": false,
"properties": {
"lines": {
"type": "array",
"items": {
"type": "string"
},
"example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."]
},
"size": {
"type": "integer",
"description": "Size in bytes of the underlying log file",
"example": 4096
},
"truncated": {
"type": "boolean",
"description": "True when the file is larger than the maximum amount of data scanned per request",
"example": false
},
"exists": {
"type": "boolean",
"description": "False when the underlying log file does not exist yet",
"example": true
}
}
}
@@ -1,6 +1,7 @@
{ {
"type": "object", "type": "object",
"minProperties": 1, "minProperties": 1,
"additionalProperties": false,
"properties": { "properties": {
"visibility": { "visibility": {
"type": "string", "type": "string",
@@ -43,6 +44,25 @@
"description": "Certificates Permissions", "description": "Certificates Permissions",
"enum": ["hidden", "view", "manage"], "enum": ["hidden", "view", "manage"],
"example": "hidden" "example": "hidden"
},
"meta": {
"type": "object",
"description": "Permission metadata",
"example": {
"proxy_host_ids": [1]
},
"properties": {
"proxy_host_ids": {
"type": "array",
"description": "Allowed Proxy Host IDs",
"example": [1],
"items": {
"type": "integer",
"description": "Proxy Host ID",
"example": 1
}
}
}
} }
} }
} }
@@ -45,6 +45,7 @@
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": 255, "maxLength": 255,
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\$/]+(?:/[^\\s\\p{Cc};{}\"'#\\\\]*)?$",
"example": "127.0.0.1" "example": "127.0.0.1"
}, },
"forward_port": { "forward_port": {
@@ -108,7 +109,8 @@
}, },
"path": { "path": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1,
"pattern": "^(?!(?:=|~\\*?|\\^~)$)(?:(?:=|~\\*?|\\^~) +)?(?:\"(?:[^\"\\\\\\p{Cc}]|\\\\[^\\p{Cc}])+\"|(?:[^\\s\\p{Cc};{}\"'#\\\\]|\\\\[^\\s\\p{Cc}])+)$"
}, },
"forward_scheme": { "forward_scheme": {
"$ref": "#/properties/forward_scheme" "$ref": "#/properties/forward_scheme"
@@ -120,10 +122,14 @@
"$ref": "#/properties/forward_port" "$ref": "#/properties/forward_port"
}, },
"forward_path": { "forward_path": {
"type": "string" "type": "string",
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\]*$"
}, },
"advanced_config": { "advanced_config": {
"type": "string" "type": "string"
},
"access_list_id": {
"$ref": "../common.json#/properties/access_list_id"
} }
} }
}, },
@@ -132,7 +138,8 @@
"path": "/app", "path": "/app",
"forward_scheme": "http", "forward_scheme": "http",
"forward_host": "example.com", "forward_host": "example.com",
"forward_port": 80 "forward_port": 80,
"access_list_id": 0
} }
] ]
}, },
@@ -57,7 +57,7 @@
"forward_domain_name": { "forward_domain_name": {
"description": "Domain Name", "description": "Domain Name",
"type": "string", "type": "string",
"pattern": "^(?:[^.*]+\\.?)+[^.]$", "pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
"example": "jc21.com" "example": "jc21.com"
}, },
"preserve_path": { "preserve_path": {
+2 -2
View File
@@ -39,12 +39,12 @@
{ {
"description": "Domain Name", "description": "Domain Name",
"type": "string", "type": "string",
"pattern": "^(?:[^.*]+\\.?)+[^.]$", "pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\$/]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
"example": "example.com" "example": "example.com"
}, },
{ {
"type": "string", "type": "string",
"format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$" "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
}, },
{ {
"type": "string", "type": "string",
@@ -0,0 +1,36 @@
{
"operationId": "getLogSources",
"summary": "Get available log sources",
"tags": ["logs"],
"security": [
{
"bearerAuth": ["admin"]
}
],
"responses": {
"200": {
"description": "200 response",
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"system": { "label": "System" },
"letsencrypt": { "label": "Let's Encrypt" },
"hosts": {
"proxy": [{ "id": 1, "label": "example.com" }],
"redirection": [],
"dead": [],
"stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
}
}
}
},
"schema": {
"$ref": "../../../components/log-sources-object.json"
}
}
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
{
"operationId": "getLogTail",
"summary": "Get the last N lines of a log source",
"tags": ["logs"],
"security": [
{
"bearerAuth": ["admin"]
}
],
"parameters": [
{
"in": "query",
"name": "type",
"required": true,
"description": "Which log source to read",
"schema": {
"type": "string",
"enum": ["system", "letsencrypt", "host"]
}
},
{
"in": "query",
"name": "host_type",
"description": "Required when type=host",
"schema": {
"type": "string",
"enum": ["proxy", "redirection", "dead", "stream"]
}
},
{
"in": "query",
"name": "host_id",
"description": "Required when type=host",
"schema": {
"type": "integer",
"minimum": 1
}
},
{
"in": "query",
"name": "channel",
"description": "Required when type=host - which log file to read (not to be confused with host_type=stream)",
"schema": {
"type": "string",
"enum": ["access", "error"]
}
},
{
"in": "query",
"name": "lines",
"description": "Number of lines to return from the end of the file",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 200
}
},
{
"in": "query",
"name": "level",
"description": "Only applicable to type=system",
"schema": {
"type": "string",
"enum": ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]
}
},
{
"in": "query",
"name": "search",
"description": "Case-insensitive plain-text search",
"schema": {
"type": "string",
"maxLength": 200
}
}
],
"responses": {
"200": {
"description": "200 response",
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"lines": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."],
"size": 4096,
"truncated": false,
"exists": true
}
}
},
"schema": {
"$ref": "../../../components/log-tail-object.json"
}
}
}
}
}
}
@@ -35,6 +35,26 @@
} }
} }
} }
},
"400": {
"description": "400 response",
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"error": {
"code": 400,
"message": "Certificate is in use by 1 proxy host. Remove it from them before deleting it."
}
}
}
},
"schema": {
"$ref": "../../../../components/error.json"
}
}
}
} }
} }
} }
@@ -35,7 +35,10 @@
"dead_hosts": "hidden", "dead_hosts": "hidden",
"proxy_hosts": "manage", "proxy_hosts": "manage",
"redirection_hosts": "hidden", "redirection_hosts": "hidden",
"streams": "hidden" "streams": "hidden",
"meta": {
"proxy_host_ids": [1]
}
} }
} }
} }
+14
View File
@@ -24,6 +24,10 @@
"name": "audit-log", "name": "audit-log",
"description": "Endpoints related to Audit Logs" "description": "Endpoints related to Audit Logs"
}, },
{
"name": "logs",
"description": "Endpoints for viewing system, Let's Encrypt and per-host logs"
},
{ {
"name": "access-lists", "name": "access-lists",
"description": "Endpoints related to Access Lists" "description": "Endpoints related to Access Lists"
@@ -81,6 +85,16 @@
"$ref": "./paths/audit-log/id/get.json" "$ref": "./paths/audit-log/id/get.json"
} }
}, },
"/logs/sources": {
"get": {
"$ref": "./paths/logs/sources/get.json"
}
},
"/logs/tail": {
"get": {
"$ref": "./paths/logs/tail/get.json"
}
},
"/nginx/access-lists": { "/nginx/access-lists": {
"get": { "get": {
"$ref": "./paths/nginx/access-lists/get.json" "$ref": "./paths/nginx/access-lists/get.json"
+6 -6
View File
@@ -46,7 +46,7 @@ const logIt = (msg, type = "info") => logger[type](
// Let's do it. // Let's do it.
const processItems = async (model, type) => { const processItems = async (model, type, hostType) => {
const rows = await model const rows = await model
.query() .query()
.where("is_deleted", 0) .where("is_deleted", 0)
@@ -60,17 +60,17 @@ const processItems = async (model, type) => {
for (const row of rows) { for (const row of rows) {
if (!DRY_RUN) { if (!DRY_RUN) {
logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`); logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
await internalNginx.configure(proxyHostModel, "proxy_host", row); await internalNginx.configure(model, hostType, row);
} else { } else {
logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`); logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
} }
} }
}; };
await processItems(proxyHostModel, "Proxy Host"); await processItems(proxyHostModel, "Proxy Host", "proxy_host");
await processItems(redirectionHostModel, "Redirection Host"); await processItems(redirectionHostModel, "Redirection Host", "redirection_host");
await processItems(deadHostModel, "404 Host"); await processItems(deadHostModel, "404 Host", "dead_host");
await processItems(streamModel, "Stream"); await processItems(streamModel, "Stream", "stream");
logIt("Completed", "success"); logIt("Completed", "success");
process.exit(0); process.exit(0);
+29 -43
View File
@@ -6,11 +6,12 @@ import certificateModel from "./models/certificate.js";
import settingModel from "./models/setting.js"; import settingModel from "./models/setting.js";
import userModel from "./models/user.js"; import userModel from "./models/user.js";
import userPermissionModel from "./models/user_permission.js"; import userPermissionModel from "./models/user_permission.js";
import fs from "node:fs/promises";
export const isSetup = async () => { export const isSetup = async () => {
const row = await userModel.query().select("id").where("is_deleted", 0).first(); const row = await userModel.query().select("id").where("is_deleted", 0).first();
return row?.id > 0; return row?.id > 0;
} };
/** /**
* Creates a default admin users if one doesn't already exist in the database * Creates a default admin users if one doesn't already exist in the database
@@ -44,18 +45,14 @@ const setupDefaultUser = async () => {
roles: ["admin"], roles: ["admin"],
}; };
const user = await userModel const user = await userModel.query().insertAndFetch(data);
.query()
.insertAndFetch(data);
await authModel await authModel.query().insert({
.query() user_id: user.id,
.insert({ type: "password",
user_id: user.id, secret: initialAdminPassword,
type: "password", meta: {},
secret: initialAdminPassword, });
meta: {},
});
await userPermissionModel.query().insert({ await userPermissionModel.query().insert({
user_id: user.id, user_id: user.id,
@@ -77,22 +74,16 @@ const setupDefaultUser = async () => {
* @returns {Promise} * @returns {Promise}
*/ */
const setupDefaultSettings = async () => { const setupDefaultSettings = async () => {
const row = await settingModel const row = await settingModel.query().select("id").where({ id: "default-site" }).first();
.query()
.select("id")
.where({ id: "default-site" })
.first();
if (!row?.id) { if (!row?.id) {
await settingModel await settingModel.query().insert({
.query() id: "default-site",
.insert({ name: "Default Site",
id: "default-site", description: "What to show when Nginx is hit with an unknown Host",
name: "Default Site", value: "congratulations",
description: "What to show when Nginx is hit with an unknown Host", meta: {},
value: "congratulations", });
meta: {},
});
logger.info("Default settings added"); logger.info("Default settings added");
} }
}; };
@@ -103,14 +94,10 @@ const setupDefaultSettings = async () => {
* @returns {Promise} * @returns {Promise}
*/ */
const setupCertbotPlugins = async () => { const setupCertbotPlugins = async () => {
const certificates = await certificateModel const certificates = await certificateModel.query().where("is_deleted", 0).andWhere("provider", "letsencrypt");
.query()
.where("is_deleted", 0)
.andWhere("provider", "letsencrypt");
if (certificates?.length) { if (certificates?.length) {
const plugins = []; const plugins = [];
const promises = [];
certificates.map((certificate) => { certificates.map((certificate) => {
if (certificate.meta && certificate.meta.dns_challenge === true) { if (certificate.meta && certificate.meta.dns_challenge === true) {
@@ -118,24 +105,23 @@ const setupCertbotPlugins = async () => {
plugins.push(certificate.meta.dns_provider); plugins.push(certificate.meta.dns_provider);
} }
// Make sure credentials file exists // Deliberately does NOT write the DNS credentials file here any more.
const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; //
// Escape single quotes and backslashes // It used to, so that a later `certbot renew` would find the path recorded in its
if (typeof certificate.meta.dns_provider_credentials === "string") { // renewal config. The effect was that every backend restart rewrote a plaintext
const escapedCredentials = certificate.meta.dns_provider_credentials // DNS provider API token for every DNS-01 certificate, and left it there.
.replaceAll("'", "\\'") //
.replaceAll("\\", "\\\\"); // internalCertificate now writes that file immediately before it runs certbot and
const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; // removes it again afterwards, so there is exactly one writer and the credential
promises.push(utils.exec(credentials_cmd)); // is on disk only for the length of a certbot run. Recreating the files at boot
} // would put every one of them straight back.
} }
return true; return true;
}); });
await installPlugins(plugins); await installPlugins(plugins);
if (promises.length) { if (plugins.length) {
await Promise.all(promises);
logger.info(`Added Certbot plugins ${plugins.join(", ")}`); logger.info(`Added Certbot plugins ${plugins.join(", ")}`);
} }
} }
+5
View File
@@ -1,4 +1,5 @@
{% if access_list_id > 0 %} {% if access_list_id > 0 %}
{% if access_list %}
{% if access_list.items.length > 0 %} {% if access_list.items.length > 0 %}
# Authorization # Authorization
auth_basic "Authorization required"; auth_basic "Authorization required";
@@ -24,4 +25,8 @@
{% else %} {% else %}
satisfy all; satisfy all;
{% endif %} {% endif %}
{% else %}
# Access List #{{ access_list_id }} could not be loaded: deny rather than serve unrestricted
deny all;
{% endif %}
{% endif %} {% endif %}
+5 -1
View File
@@ -2,7 +2,11 @@
{% if ssl_forced == 1 or ssl_forced == true %} {% if ssl_forced == 1 or ssl_forced == true %}
{% if hsts_enabled == 1 or hsts_enabled == true %} {% if hsts_enabled == 1 or hsts_enabled == true %}
# HSTS (ngx_http_headers_module is required) (63072000 seconds = 2 years) # HSTS (ngx_http_headers_module is required) (63072000 seconds = 2 years)
add_header Strict-Transport-Security $hsts_header always; {% if hsts_subdomains == 1 or hsts_subdomains == true %}
add_header Strict-Transport-Security $hsts_header_subdomains always;
{% else %}
add_header Strict-Transport-Security $hsts_header_plain always;
{% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
+5 -2
View File
@@ -1,3 +1,6 @@
map $scheme $hsts_header { map $scheme $hsts_header_plain {
https "max-age=63072000;{% if hsts_subdomains == 1 or hsts_subdomains == true -%} includeSubDomains;{% endif %} preload"; https "max-age=63072000; preload";
}
map $scheme $hsts_header_subdomains {
https "max-age=63072000;includeSubDomains; preload";
} }
+1 -1
View File
@@ -1,7 +1,7 @@
location {{ path }} { location {{ path }} {
{{ advanced_config }} {{ advanced_config }}
proxy_set_header Host $host; proxy_set_header Host $forward_host;
proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
+4 -4
View File
@@ -5,8 +5,8 @@
{% if enabled %} {% if enabled %}
{% if tcp_forwarding == 1 or tcp_forwarding == true -%} {% if tcp_forwarding == 1 or tcp_forwarding == true -%}
server { server {
listen {{ incoming_port }} {%- if certificate %} ssl {%- endif %}; listen {{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %}; {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
{%- include "_certificates_stream.conf" %} {%- include "_certificates_stream.conf" %}
@@ -23,8 +23,8 @@ server {
{% if udp_forwarding == 1 or udp_forwarding == true -%} {% if udp_forwarding == 1 or udp_forwarding == true -%}
server { server {
listen {{ incoming_port }} udp; listen {{ incoming_port }} udp reuseport;
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp; {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp reuseport;
proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
+550
View File
@@ -0,0 +1,550 @@
/**
* Regression tests: values a non-admin can set must never add nginx directives.
*
* Templates write these values into config as-is, so each one is checked three ways:
* - the API rejects injection attempts (real request schemas, real validator)
* - accepted values, rendered through the real templates, produce exactly the same nginx
* statement structure as a plain value (parsed with nginx's tokenizer rules)
* - generateConfig's render-time check refuses unsafe values already stored in the database
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import { describe, it } from "node:test";
import {
assertHostRenderable,
isValid,
isValidClientAddress,
isValidStreamHost,
patterns,
} from "../lib/nginx-values.js";
import utils from "../lib/utils.js";
import apiValidator from "../lib/validator/api.js";
import { getCompiledSchema, getValidationSchema } from "../schema/index.js";
const TEMPLATES = new URL("../templates/", import.meta.url);
// ---------------------------------------------------------------------------------------------
// A tokenizer following ngx_conf_read_token: whitespace separates, ";" ends a statement, "{"
// opens a block, "}" closes one, "#" at the start of a token comments to end of line, quotes
// group, "\" escapes the next character, and "${" doesn't open a block.
// ---------------------------------------------------------------------------------------------
const parseNginx = (text) => {
const root = [];
const stack = [root];
let args = [];
let i = 0;
const n = text.length;
const isSpace = (c) => c === " " || c === "\t" || c === "\r" || c === "\n";
while (i < n) {
const c = text[i];
if (isSpace(c)) {
i++;
} else if (c === "#") {
while (i < n && text[i] !== "\n") i++;
} else if (c === ";") {
if (!args.length) throw new Error(`unexpected ";" at ${i}`);
stack.at(-1).push({ name: args[0], args: args.slice(1) });
args = [];
i++;
} else if (c === "{") {
if (!args.length) throw new Error(`unexpected "{" at ${i}`);
const block = { name: args[0], args: args.slice(1), block: [] };
stack.at(-1).push(block);
stack.push(block.block);
args = [];
i++;
} else if (c === "}") {
if (args.length) throw new Error(`unexpected "}" at ${i}`);
if (stack.length === 1) throw new Error(`unbalanced "}" at ${i}`);
stack.pop();
i++;
} else if (c === '"' || c === "'") {
let tok = c;
i++;
while (i < n && text[i] !== c) {
if (text[i] === "\\" && i + 1 < n) {
tok += text[i] + text[i + 1];
i += 2;
} else {
tok += text[i++];
}
}
if (i >= n) throw new Error("unterminated quoted string");
tok += c;
i++;
if (i < n && !isSpace(text[i]) && text[i] !== ";" && text[i] !== "{") {
throw new Error(`unexpected "${text[i]}" after quoted string`);
}
args.push(tok);
} else {
let tok = "";
while (i < n) {
const d = text[i];
if (d === "\\" && i + 1 < n) {
tok += d + text[i + 1];
i += 2;
continue;
}
if (d === "$" && text[i + 1] === "{") {
tok += "${";
i += 2;
continue;
}
if (isSpace(d) || d === ";" || d === "{") break;
tok += d;
i++;
}
args.push(tok);
}
}
if (args.length) throw new Error("statement without ;");
if (stack.length !== 1) throw new Error("unclosed block");
return root;
};
// Statement names and argument counts, nested; values themselves don't matter
const shape = (stmts) =>
stmts.map((s) => `${s.name}/${s.args.length}${s.block ? `{${shape(s.block).join(",")}}` : ""}`);
const find = (stmts, name) => {
for (const s of stmts) {
if (s.name === name) return s;
if (s.block) {
const f = find(s.block, name);
if (f) return f;
}
}
return null;
};
const render = (file, ctx) =>
utils.getRenderEngine().parseAndRender(fs.readFileSync(new URL(file, TEMPLATES), "utf8"), ctx);
// Same structure as a plain value, or throws (a value that breaks parsing changes it too)
const sameShapeAs = async (file, makeCtx, plain, value) => {
const base = shape(parseNginx(await render(file, makeCtx(plain))));
let got;
try {
got = shape(parseNginx(await render(file, makeCtx(value))));
} catch {
return false;
}
return JSON.stringify(got) === JSON.stringify(base);
};
// ---------------------------------------------------------------------------------------------
// Corpora. "injections" add or change directives when rendered unchecked; "rejected" are other
// values the rules refuse (they would break or confuse the config without injecting).
// ---------------------------------------------------------------------------------------------
const cases = {
locationPath: {
valid: [
"/",
"/api",
"/api/",
"= /exact",
"~ \\.php$",
"~* \\.(jpg|png|gif)$",
"^~ /static/",
'~ "^/v[0-9]{1,2}/"',
'"/with space"',
"/über",
"/a$b",
"/a\\;b",
],
injections: [
"/x { return 200; } location /y",
"/x {} include /etc/passwd; location /y",
"/a; } server { listen 8080",
"/a\nreturn 200 x;\nlocation /b",
'"/a" { return 200; } location "/b"',
"/a {",
],
rejected: ["", " /a", "/a b", "/a#", "/a\\", '"/a', '"/a\\"', "'/a'", "~", "~ /a\tb", "/a}", "/a\u0000"],
},
forwardHost: {
valid: ["127.0.0.1", "backend", "my_service.internal", "10.0.0.1/path/", "backend/$1", "[::1]", "host:8080"],
injections: [
"a; return 200; #",
'a"; return 200; set $x "',
"a:80; } location /x { return 200",
"a\nreturn 200;",
],
rejected: ["", "a b", "a'", "a{", "a}", "a#b", "a\\b", "$http_host", "a$b", "/path", "a/b c"],
},
forwardPath: {
valid: ["", "/", "/x", "/x/$1/"],
injections: ["/x; return 200", "/x\nreturn 200"],
rejected: ["/x y", "/x#", "/x{", "/x\\"],
},
redirectDomain: {
valid: [
"example.com",
"example.com:8443",
"example.com/path",
"sub.example.co.uk/a?b=c",
"example.com/$1",
"ab",
],
injections: ["example.com; return 200 x; #", "example.com;\nreturn 200", "a.com { return 200; }"],
rejected: ["a b.com", "a.com#x", 'a.com"', "a.com\\", ".com", "a..b", "a", "a.com."],
},
streamHost: {
valid: ["example.com", "1.2.3.4", "::1", "backend.internal"],
injections: ["a.com:80; } server { listen 9999", "a.com;\nlisten 1"],
rejected: ["a.com/x", "$host", "a b", "a.com#"],
},
domainName: {
valid: ["example.com", "*.example.com", "xn--bcher-kva.example", "a-b.example"],
injections: ["a.com\nreturn 200 x", "a.com\n} server {"],
rejected: ["a\tb.com", "a;b", "a{b", "a b"],
},
clientAddress: {
valid: ["192.168.0.1", "10.0.0.0/8", "all", "::1", "2001:db8::/32", "::ffff:192.168.1.1", "fe80::1"],
injections: ["::1%x; allow all", "::1%x\nallow all", "1.2.3.4; allow all"],
rejected: ["fe80::1%eth0", "s::1", "1.2.3.4/33", "::ffff:1d.1.1.1"],
},
accessUsername: {
valid: ["admin", "john smith", "ü"],
injections: [],
rejected: ["a:b", "a\nroot:$apr1$x$y", "\u0000"],
},
};
const checkers = {
locationPath: (v) => isValid("locationPath", v),
forwardHost: (v) => isValid("forwardHost", v),
forwardPath: (v) => isValid("forwardPath", v),
redirectDomain: (v) => isValid("redirectDomain", v),
streamHost: isValidStreamHost,
domainName: (v) => isValid("domainName", v),
clientAddress: isValidClientAddress,
accessUsername: (v) => isValid("accessUsername", v),
};
describe("nginx value rules", () => {
for (const [name, { valid, injections, rejected }] of Object.entries(cases)) {
it(`${name}: accepts valid values`, () => {
for (const v of valid) assert.ok(checkers[name](v), `should accept ${JSON.stringify(v)}`);
});
it(`${name}: rejects injections and other unsafe values`, () => {
for (const v of [...injections, ...rejected]) {
assert.ok(!checkers[name](v), `should reject ${JSON.stringify(v)}`);
}
});
}
it("redirect domain rule matches the old rule for token-safe values", () => {
const old = /^(?:[^.*]+\.?)+[^.]$/u;
const corpus = [
"ab",
"a.b",
"a.b.c",
"a..b",
".a",
"a.",
"a*",
"a.*",
"*a",
"a*b",
"a.b:80/x",
"x",
"ab.",
"a.b.",
];
for (const v of corpus) {
assert.equal(isValid("redirectDomain", v), old.test(v), `differs for ${JSON.stringify(v)}`);
}
});
it("domain rules don't backtrack exponentially", () => {
const evil = `${"a".repeat(30000)}.`;
const start = process.hrtime.bigint();
isValid("redirectDomain", evil);
isValidStreamHost(evil);
const ms = Number(process.hrtime.bigint() - start) / 1e6;
assert.ok(ms < 1000, `took ${ms}ms`);
});
});
describe("API request validation", async () => {
await getCompiledSchema();
const location = (o) => ({ path: "/api", forward_scheme: "http", forward_host: "backend", forward_port: 80, ...o });
const proxyHost = (o) => ({
domain_names: ["example.com"],
forward_scheme: "http",
forward_host: "backend",
forward_port: 80,
...o,
});
const redirection = (o) => ({
domain_names: ["example.com"],
forward_scheme: "http",
forward_http_code: 301,
forward_domain_name: "example.org",
...o,
});
const stream = (o) => ({
incoming_port: 9000,
forwarding_host: "backend",
forwarding_port: 80,
tcp_forwarding: true,
udp_forwarding: false,
...o,
});
// [field, endpoint, method, payload builder]
const endpoints = {
locationPath: [
["/nginx/proxy-hosts", "post", (v) => proxyHost({ locations: [location({ path: v })] })],
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ path: v })] })],
],
forwardHost: [
["/nginx/proxy-hosts", "post", (v) => proxyHost({ forward_host: v })],
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ forward_host: v })],
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_host: v })] })],
],
forwardPath: [["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_path: v })] })]],
redirectDomain: [
["/nginx/redirection-hosts", "post", (v) => redirection({ forward_domain_name: v })],
["/nginx/redirection-hosts/{hostID}", "put", (v) => ({ forward_domain_name: v })],
],
streamHost: [
["/nginx/streams", "post", (v) => stream({ forwarding_host: v })],
["/nginx/streams/{streamID}", "put", (v) => ({ forwarding_host: v })],
],
domainName: [
["/nginx/proxy-hosts", "post", (v) => proxyHost({ domain_names: [v] })],
["/nginx/redirection-hosts", "post", (v) => redirection({ domain_names: [v] })],
["/nginx/dead-hosts", "post", (v) => ({ domain_names: [v] })],
["/nginx/certificates", "post", (v) => ({ provider: "letsencrypt", domain_names: [v] })],
],
clientAddress: [
["/nginx/access-lists", "post", (v) => ({ name: "x", clients: [{ directive: "allow", address: v }] })],
["/nginx/access-lists/{listID}", "put", (v) => ({ clients: [{ directive: "allow", address: v }] })],
],
accessUsername: [
["/nginx/access-lists", "post", (v) => ({ name: "x", items: [{ username: v, password: "pw" }] })],
["/nginx/access-lists/{listID}", "put", (v) => ({ items: [{ username: v, password: "pw" }] })],
],
};
const validate = (path, method, payload) => {
const schema = getValidationSchema(path, method);
assert.ok(schema, `no request schema for ${method} ${path}`);
return apiValidator(schema, payload);
};
for (const [name, list] of Object.entries(endpoints)) {
for (const [path, method, build] of list) {
it(`${method.toUpperCase()} ${path} ${name}: rejects injections`, async () => {
for (const v of [...cases[name].injections, ...cases[name].rejected]) {
if (v === "") continue; // minLength cases, covered by the rules above
await assert.rejects(validate(path, method, build(v)), /must/, `accepted ${JSON.stringify(v)}`);
}
});
it(`${method.toUpperCase()} ${path} ${name}: accepts valid values`, async () => {
for (const v of cases[name].valid) {
if (name === "forwardHost" && v === "") continue;
await validate(path, method, build(v));
}
});
}
}
it("schema patterns are the ones the render-time check uses", async () => {
const s = await getCompiledSchema();
const body = (path, method) => s.paths[path][method].requestBody.content["application/json"].schema;
const ph = body("/nginx/proxy-hosts", "post").properties;
const loc = ph.locations.items.properties;
const al = body("/nginx/access-lists", "post").properties;
assert.equal(ph.domain_names.items.pattern, patterns.domainName);
assert.equal(body("/nginx/certificates", "post").properties.domain_names.items.pattern, patterns.domainName);
assert.equal(ph.forward_host.pattern, patterns.forwardHost);
assert.equal(loc.path.pattern, patterns.locationPath);
assert.equal(loc.forward_host.pattern, patterns.forwardHost);
assert.equal(loc.forward_path.pattern, patterns.forwardPath);
assert.equal(
body("/nginx/redirection-hosts", "post").properties.forward_domain_name.pattern,
patterns.redirectDomain,
);
assert.equal(body("/nginx/streams", "post").properties.forwarding_host.anyOf[0].pattern, patterns.streamDomain);
assert.equal(al.clients.items.properties.address.oneOf[1].pattern, patterns.clientIpv6);
assert.equal(al.items.items.properties.username.pattern, patterns.accessUsername);
});
});
describe("rendered templates", () => {
const locationCtx = (field) => (v) => ({
path: "/api",
forward_scheme: "http",
forward_host: "backend",
forward_port: 8080,
forward_path: "",
advanced_config: "",
access_list_id: 0,
[field]: v,
});
const proxyCtx = (field) => (v) => ({
enabled: true,
id: 1,
domain_names: ["example.com"],
forward_scheme: "http",
forward_host: "backend",
forward_port: 80,
certificate_id: 0,
locations: "",
advanced_config: "",
use_default_location: true,
ipv6: true,
[field]: v,
});
const redirectCtx = (v) => ({
enabled: true,
id: 1,
domain_names: ["example.com"],
forward_scheme: "http",
forward_http_code: 301,
forward_domain_name: v,
preserve_path: true,
advanced_config: "",
use_default_location: true,
ipv6: true,
});
const streamCtx = (v) => ({
enabled: true,
id: 1,
incoming_port: 9000,
forwarding_host: v,
forwarding_port: 80,
tcp_forwarding: true,
udp_forwarding: true,
ipv6: true,
});
const accessCtx = (v) => ({
access_list_id: 1,
access_list: { items: [], clients: [{ directive: "allow", address: v }], satisfy_any: false },
});
// A location modifier is a separate argument, so compare with a plain path using the same one
const plainPath = (v) => {
const m = v.match(/^(=|~\*?|\^~) +/);
return m ? `${m[1]} /api` : "/api";
};
// [template, context builder, plain value (or builder), injections that apply to it]
const targets = {
locationPath: [["_location.conf", locationCtx("path"), plainPath]],
forwardHost: [
["_location.conf", locationCtx("forward_host"), "backend"],
// Rendered inside double quotes here, so only a quote can break out
["proxy_host.conf", proxyCtx("forward_host"), "backend", ['a"; return 200; set $x "', 'a" ; } server { "']],
],
forwardPath: [["_location.conf", locationCtx("forward_path"), "/x"]],
redirectDomain: [["redirection_host.conf", redirectCtx, "example.org"]],
streamHost: [["stream.conf", streamCtx, "backend"]],
domainName: [["proxy_host.conf", (v) => proxyCtx("domain_names")([v]), "example.com"]],
clientAddress: [["_access.conf", accessCtx, "10.0.0.1"]],
};
for (const [name, list] of Object.entries(targets)) {
for (const [file, ctx, plain, injections = cases[name].injections] of list) {
const plainFor = (v) => (typeof plain === "function" ? plain(v) : plain);
it(`${file} ${name}: accepted values keep the statement structure`, async () => {
for (const v of cases[name].valid) {
if (v === "" && name !== "forwardPath") continue;
// generateConfig brackets IPv6 stream hosts before rendering
const value = name === "streamHost" && v.includes(":") ? `[${v}]` : v;
assert.ok(
await sameShapeAs(file, ctx, plainFor(v), value),
`${JSON.stringify(v)} changed the config`,
);
}
});
it(`${file} ${name}: the injection corpus would change it unchecked`, async () => {
assert.ok(injections.length > 0);
for (const v of injections) {
assert.ok(!checkers[name](v), `rules accept ${JSON.stringify(v)}`);
assert.ok(
!(await sameShapeAs(file, ctx, plainFor(v), v)),
`${JSON.stringify(v)} is not an injection`,
);
}
});
}
}
it("location path renders as the location's own arguments", async () => {
for (const v of cases.locationPath.valid) {
const loc = find(parseNginx(await render("_location.conf", locationCtx("path")(v))), "location");
assert.equal(loc.args.join(" "), v.replace(/ +/, " "));
}
});
});
describe("render-time check (rows saved before validation)", () => {
const host = (o) => ({
id: 7,
domain_names: ["example.com"],
forward_host: "backend",
locations: [],
...o,
});
it("passes valid hosts", () => {
assertHostRenderable("proxy_host", host({ locations: [{ path: "~* \\.php$", forward_host: "10.0.0.1/x/" }] }));
assertHostRenderable("redirection_host", host({ forward_domain_name: "example.org/path" }));
assertHostRenderable("dead_host", host({}));
assertHostRenderable("stream", { id: 1, forwarding_host: "::1" });
assertHostRenderable(
"proxy_host",
host({ access_list: { clients: [{ directive: "allow", address: "10.0.0.0/8" }] } }),
);
});
it("still renders stored values the API no longer accepts but that can't add directives", () => {
// e.g. an IPv6 literal server name, as the host-header smoke test renders
assertHostRenderable("proxy_host", host({ domain_names: ["host.example.test", "[::1]"] }));
assertHostRenderable("proxy_host", host({ forward_host: "$upstream" }));
assertHostRenderable("redirection_host", host({ forward_domain_name: "a" }));
assertHostRenderable("stream", { id: 1, forwarding_host: "a.com/x" });
});
const refuse = (type, row, what) => {
assert.throws(
() => assertHostRenderable(type, row),
(err) => err.unsafeNginxValue === true && err.message.includes(what),
);
};
it("refuses every injected field", () => {
for (const v of cases.locationPath.injections) {
refuse("proxy_host", host({ locations: [{ path: v, forward_host: "b" }] }), "location path");
}
for (const v of cases.forwardHost.injections) {
refuse("proxy_host", host({ forward_host: v }), "forward host");
refuse("proxy_host", host({ locations: [{ path: "/a", forward_host: v }] }), "location forward host");
}
for (const v of cases.forwardPath.injections) {
refuse(
"proxy_host",
host({ locations: [{ path: "/a", forward_host: "b", forward_path: v }] }),
"forward path",
);
}
for (const v of cases.redirectDomain.injections) {
refuse("redirection_host", host({ forward_domain_name: v }), "forward domain");
}
for (const v of cases.streamHost.injections) {
refuse("stream", { id: 1, forwarding_host: v }, "forwarding host");
}
for (const v of cases.domainName.injections) {
refuse("dead_host", host({ domain_names: [v] }), "domain name");
}
for (const v of cases.clientAddress.injections) {
refuse("proxy_host", host({ access_list: { clients: [{ directive: "allow", address: v }] } }), "address");
}
});
});
+480 -447
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -25,7 +25,7 @@ RUN curl -sL 'https://taskfile.dev/install.sh' | sh
COPY rootfs / COPY rootfs /
COPY scripts/install-s6 /tmp/install-s6 COPY scripts/install-s6 /tmp/install-s6
RUN rm -f /etc/nginx/conf.d/production.conf \ RUN rm -f /etc/nginx/conf.d/production.conf.template \
&& chmod 644 /etc/logrotate.d/nginx-proxy-manager \ && chmod 644 /etc/logrotate.d/nginx-proxy-manager \
&& /tmp/install-s6 "${TARGETPLATFORM}" \ && /tmp/install-s6 "${TARGETPLATFORM}" \
&& rm -f /tmp/install-s6 \ && rm -f /tmp/install-s6 \
+1
View File
@@ -28,6 +28,7 @@ acl SSL_ports port 443
acl Safe_ports port 80 # http acl Safe_ports port 80 # http
acl Safe_ports port 81 acl Safe_ports port 81
acl Safe_ports port 443 # https acl Safe_ports port 443 # https
acl Safe_ports port 8000 # for CI testing with custom admin port
# #
# Recommended minimum Access Permission configuration: # Recommended minimum Access Permission configuration:
+4
View File
@@ -3,6 +3,7 @@ services:
cypress: cypress:
environment: environment:
CYPRESS_stack: "postgres" CYPRESS_stack: "postgres"
NPM_ADMIN_PORT: 8000
fullstack: fullstack:
environment: environment:
@@ -11,11 +12,14 @@ services:
DB_POSTGRES_USER: "npm" DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass" DB_POSTGRES_PASSWORD: "npmpass"
DB_POSTGRES_NAME: "npm" DB_POSTGRES_NAME: "npm"
NPM_ADMIN_PORT: 8000
depends_on: depends_on:
- db-postgres - db-postgres
- authentik - authentik
- authentik-worker - authentik-worker
- authentik-ldap - authentik-ldap
expose:
- "8000/tcp"
db-postgres: db-postgres:
image: postgres:17 image: postgres:17
+12
View File
@@ -37,6 +37,17 @@ services:
- website1.example.com - website1.example.com
- website2.example.com - website2.example.com
- website3.example.com - website3.example.com
- website4.example.com
examplesite:
image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
build:
context: ../test/docker
dockerfile: Dockerfile.website
expose:
- "80/tcp"
networks:
- fulltest
stepca: stepca:
image: nginxproxymanager/testca image: nginxproxymanager/testca
@@ -105,6 +116,7 @@ services:
environment: environment:
HTTP_PROXY: "squid:3128" HTTP_PROXY: "squid:3128"
HTTPS_PROXY: "squid:3128" HTTPS_PROXY: "squid:3128"
NPM_ADMIN_PORT: 81
volumes: volumes:
- "cypress_logs:/test/results" - "cypress_logs:/test/results"
- "./dev/resolv.conf:/etc/resolv.conf:ro" - "./dev/resolv.conf:/etc/resolv.conf:ro"
+1
View File
@@ -16,6 +16,7 @@ services:
- website1.example.com - website1.example.com
- website2.example.com - website2.example.com
- website3.example.com - website3.example.com
- website4.example.com
environment: environment:
TZ: "${TZ:-Australia/Brisbane}" TZ: "${TZ:-Australia/Brisbane}"
PUID: 1000 PUID: 1000
@@ -25,3 +25,13 @@
kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true
endscript endscript
} }
/data/logs/backend.log {
su npm npm
size 10M
rotate 5
missingok
notifempty
compress
copytruncate
}
+3
View File
@@ -17,6 +17,9 @@ server {
proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
# The TCP peer before realip processing. In development there is no peer secret
# (see production.conf.template), so the backend ignores this and limits on the socket
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
proxy_pass http://127.0.0.1:3000/; proxy_pass http://127.0.0.1:3000/;
proxy_read_timeout 15m; proxy_read_timeout 15m;
@@ -3,7 +3,7 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|
# use the public cache # use the public cache
proxy_cache public-cache; proxy_cache public-cache;
proxy_cache_key $host$request_uri; proxy_cache_key $forward_host$request_uri;
# ignore these headers for media # ignore these headers for media
proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires; proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires;
@@ -1,5 +1,5 @@
add_header X-Served-By $host; add_header X-Served-By $host;
proxy_set_header Host $host; proxy_set_header Host $forward_host;
proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme; proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme;
proxy_set_header X-Forwarded-Proto $x_forwarded_proto; proxy_set_header X-Forwarded-Proto $x_forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -1,7 +1,7 @@
# Admin Interface # Admin Interface
server { server {
listen 81 default; listen {{NPM_ADMIN_PORT}} default;
listen [::]:81 default; listen [::]:{{NPM_ADMIN_PORT}} default;
server_name nginxproxymanager; server_name nginxproxymanager;
root /app/frontend; root /app/frontend;
@@ -17,6 +17,11 @@ server {
proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
# The TCP peer before realip processing; the backend rate limits on this because
# X-Real-IP can be set by any client in the trusted private ranges. The secret, generated
# at startup, is how the backend knows this header came from here.
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
proxy_set_header X-NPM-Peer-Secret "{{NPM_PEER_SECRET}}";
proxy_pass http://127.0.0.1:3000/; proxy_pass http://127.0.0.1:3000/;
proxy_read_timeout 15m; proxy_read_timeout 15m;
+12
View File
@@ -52,6 +52,18 @@ http {
# Dynamically generated resolvers file # Dynamically generated resolvers file
include /etc/nginx/conf.d/include/resolvers[.]conf; include /etc/nginx/conf.d/include/resolvers[.]conf;
# Forwarded Host: nginx's normalised $host (lowercased, taken from the request line for
# absolute-form requests) plus the client's port, so upstreams that compare Host with
# Origin/Referer on non-standard ports keep working. The port is only carried over when
# the Host header names the same host nginx routed on; otherwise (no Host, absolute-form
# URI naming a different host) it falls back to bare $host. Using $host rather than the
# raw header keeps case variants and mismatched Host values out of upstream requests and
# the asset cache key.
map "$host|$http_host" $forward_host {
"~*^(?<fwd_name>[^|]+)\|\k<fwd_name>(?<fwd_port>:[0-9]+)$" "$host$fwd_port";
default $host;
}
# Default upstream scheme # Default upstream scheme
map $host $forward_scheme { map $host $forward_scheme {
default http; default http;
@@ -17,6 +17,7 @@ fi
. /etc/s6-overlay/s6-rc.d/prepare/20-paths.sh . /etc/s6-overlay/s6-rc.d/prepare/20-paths.sh
. /etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh . /etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
. /etc/s6-overlay/s6-rc.d/prepare/40-dynamic.sh . /etc/s6-overlay/s6-rc.d/prepare/40-dynamic.sh
. /etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
. /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh . /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh . /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh
. /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh . /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh
@@ -39,3 +39,7 @@ touch /var/log/nginx/error.log || true
chmod 777 /var/log/nginx/error.log || true chmod 777 /var/log/nginx/error.log || true
chmod -R 777 /var/cache/nginx || true chmod -R 777 /var/cache/nginx || true
chmod 644 /etc/logrotate.d/nginx-proxy-manager chmod 644 /etc/logrotate.d/nginx-proxy-manager
# nginx has not been started yet, so any pid file here is stale. Remove it,
# otherwise a pid file owned by another user stops nginx starting as PUID:PGID
rm -f /run/nginx/nginx.pid
@@ -46,6 +46,14 @@ for loc in "${locations[@]}"; do
chownit "$loc" chownit "$loc"
done done
# Ensure the JWT key file is owned by the runtime user, even when the /data
# directory ownership already matches PUID:PGID (chownit skips recursion then)
if [ -f /data/keys.json ]; then
if [ "$(stat -c '%u:%g' '/data/keys.json')" != "$PUID:$PGID" ]; then
chown "$PUID:$PGID" /data/keys.json
fi
fi
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
log_info 'Skipping ownership change of certbot directories' log_info 'Skipping ownership change of certbot directories'
else else
@@ -0,0 +1,41 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# This command reads the `NPM_ADMIN_PORT` env var and will fall
# back to 81 if this is not set or is not a number.
set -e
log_info 'Admin Port ...'
NPM_ADMIN_PORT="${NPM_ADMIN_PORT:-81}"
# ensure admin port is a number
if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
echo "WARNING: NPM_ADMIN_PORT must be a number. Defaulting to 81" >&2
NPM_ADMIN_PORT=81
fi
PRODFILE="/etc/nginx/conf.d/production.conf"
# A new secret on every start. The admin nginx sends it with X-NPM-Peer-Addr, and the backend
# only trusts that header when the secret matches, so a proxy host or stream forwarding to the
# backend port can't pass a forged one through.
NPM_PEER_SECRET="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
if [ "${#NPM_PEER_SECRET}" -ne 64 ]; then
log_fatal "Failed to generate the backend peer secret"
fi
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g;s/\{\{NPM_PEER_SECRET\}\}/${NPM_PEER_SECRET}/g"
if is_mounted "$PRODFILE"; then
echo "WARNING: skipping ${PRODFILE} — mounted file" >&2
elif [ -f "$PRODFILE.template" ]; then
if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then
# success
log_info "Generated ${PRODFILE} from template"
# Hand the secret to the backend; with-contenv loads this directory into its environment
printf '%s' "$NPM_PEER_SECRET" > /var/run/s6/container_environment/NPM_PEER_SECRET
else
log_fatal "Failed to generate ${PRODFILE} from template"
fi
fi
@@ -26,7 +26,10 @@ process_folder () {
do do
echo "- ${FILE}" echo "- ${FILE}"
TMPFILE="${FILE}.tmp" TMPFILE="${FILE}.tmp"
if sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
if is_mounted "${FILE}"; then
echo "WARNING: skipping ${FILE} — mounted file" >&2
elif sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
mv "$TMPFILE" "$FILE" mv "$TMPFILE" "$FILE"
else else
echo "WARNING: skipping ${FILE} — sed produced empty output" >&2 echo "WARNING: skipping ${FILE} — sed produced empty output" >&2
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
OK=$(curl --silent http://127.0.0.1:81/api/ | jq --raw-output '.status') OK=$(curl --silent "http://127.0.0.1:${NPM_ADMIN_PORT:-81}/api/" | jq --raw-output '.status')
if [ "$OK" == "OK" ]; then if [ "$OK" == "OK" ]; then
echo "OK" echo "OK"
+4
View File
@@ -70,3 +70,7 @@ is_true () {
echo '0' echo '0'
fi fi
} }
is_mounted() {
awk -v p="$1" '$5 == p { found=1 } END { exit !found }' /proc/self/mountinfo
}
+1 -1
View File
@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
GREEN='\E[1;32m' GREEN='\E[1;32m'
RESET='\E[0m' RESET='\E[0m'
S6_OVERLAY_VERSION=3.2.1.0 S6_OVERLAY_VERSION=3.2.3.2
TARGETPLATFORM=${1:-linux/amd64} TARGETPLATFORM=${1:-linux/amd64}
# Determine the correct binary file for the architecture given # Determine the correct binary file for the architecture given
+18
View File
@@ -248,3 +248,21 @@ On startup, we generate a resolvers directive for Nginx unless this is defined:
In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file
and then the `/etc/resolv.conf`. and then the `/etc/resolv.conf`.
## Changing the Admin UI port from 81 to something else
First, add an env var to your docker compose file:
```yml
environment:
NPM_ADMIN_PORT: '8000'
```
And you'll probably want to expose that port as well
```yml
ports:
- '8000:8000'
```
Then you'll be able to access admin UI at `http://localhost:8000`
+435 -435
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
src/locale/lang src/locale/lang
# Logs # Logs
logs /logs/
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+73 -90
View File
@@ -1,92 +1,75 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": [ "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
"**/*.ts", },
"**/*.tsx", "formatter": {
"**/*.js", "enabled": true,
"**/*.jsx", "indentStyle": "tab",
"!**/dist/**/*" "indentWidth": 4,
] "lineWidth": 120,
}, "formatWithErrors": true
"formatter": { },
"enabled": true, "assist": {
"indentStyle": "tab", "actions": {
"indentWidth": 4, "source": {
"lineWidth": 120, "organizeImports": {
"formatWithErrors": true "level": "on",
}, "options": {
"assist": { "groups": [
"actions": { ":BUN:",
"source": { ":NODE:",
"organizeImports": { ["npm:*", "npm:*/**"],
"level": "on", ":PACKAGE_WITH_PROTOCOL:",
"options": { ":URL:",
"groups": [ ":PACKAGE:",
":BUN:", ["/src/*", "/src/**"],
":NODE:", ["/**"],
[ ["#*", "#*/**"],
"npm:*", ":PATH:"
"npm:*/**" ]
], }
":PACKAGE_WITH_PROTOCOL:", }
":URL:", }
":PACKAGE:", }
[ },
"/src/*", "linter": {
"/src/**" "enabled": true,
], "rules": {
[ "preset": "recommended",
"/**" "correctness": {
], "useUniqueElementIds": "off"
[ },
"#*", "suspicious": {
"#*/**" "noExplicitAny": "off",
], "noArrayIndexKey": "off"
":PATH:" },
] "performance": {
} "noDelete": "off"
} },
} "nursery": "off",
} "a11y": {
}, "useSemanticElements": "off",
"linter": { "useValidAnchor": "off"
"enabled": true, },
"rules": { "style": {
"recommended": true, "noParameterAssign": "error",
"correctness": { "useAsConstAssertion": "error",
"useUniqueElementIds": "off" "useDefaultParameterLast": "error",
}, "useEnumInitializers": "error",
"suspicious": { "useSelfClosingElements": "error",
"noExplicitAny": "off", "useSingleVarDeclarator": "error",
"noArrayIndexKey": "off" "noUnusedTemplateLiteral": "error",
}, "useNumberNamespace": "error",
"performance": { "noInferrableTypes": "error",
"noDelete": "off" "noUselessElse": "error"
}, }
"nursery": "off", }
"a11y": { }
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
} }
Executable → Regular
+4
View File
@@ -23,12 +23,16 @@ const allLocales = [
["cs", "cs-CZ"], ["cs", "cs-CZ"],
["vi", "vi-VN"], ["vi", "vi-VN"],
["zh", "zh-CN"], ["zh", "zh-CN"],
["zh-tw", "zh-TW"],
["ko", "ko-KR"], ["ko", "ko-KR"],
["bg", "bg-BG"], ["bg", "bg-BG"],
["id", "id-ID"], ["id", "id-ID"],
["tr", "tr-TR"], ["tr", "tr-TR"],
["hu", "hu-HU"], ["hu", "hu-HU"],
["no", "no-NO"], ["no", "no-NO"],
["uk", "uk-UA"],
["az", "az-AZ"],
["fa", "fa-IR"],
]; ];
const ignoreUnused = [/^.*$/]; const ignoreUnused = [/^.*$/];
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more