Compare commits

...
121 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
141 changed files with 8282 additions and 3000 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
@@ -28,4 +38,3 @@ you change will break API for existing users -->
- [ ] AI was used to write this
- [ ] AI was used to review this
+1
View File
@@ -9,4 +9,5 @@ test/node_modules
docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf
.claude
test/__pycache__
+1 -1
View File
@@ -1 +1 @@
2.16.0
2.17.0
+1 -1
View File
@@ -1,7 +1,7 @@
<p align="center">
<img src="https://nginxproxymanager.com/github.png">
<br><br>
<img src="https://img.shields.io/badge/version-2.16.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">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
+31 -15
View File
@@ -44,16 +44,16 @@
"dependencies": "",
"full_plugin_name": "dns-baidu",
"name": "baidu",
"package_name": "certbot-dns-baidu",
"version": "~=0.1.1"
"package_name": "certbot-dns-baidu-bcd",
"version": "~=2.0.0"
},
"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": "",
"full_plugin_name": "beget-plugin",
"full_plugin_name": "dns-beget-plugin",
"name": "Beget",
"package_name": "certbot-beget-plugin",
"version": "~=1.0.0.dev9"
"package_name": "certbot-dns-beget-plugin",
"version": "~=1.0.0"
},
"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",
@@ -173,7 +173,7 @@
"full_plugin_name": "dns-multi",
"name": "DnsMulti",
"package_name": "certbot-dns-multi",
"version": "~=4.9"
"version": "~=5.6.0"
},
"dnspod": {
"credentials": "dns_dnspod_email = \"email@example.com\"\ndns_dnspod_api_token = \"id,key\"",
@@ -224,12 +224,12 @@
"version": "~=0.1.2"
},
"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",
"dependencies": "",
"full_plugin_name": "edgedns",
"name": "Akamai Edge DNS",
"package_name": "certbot-plugin-edgedns",
"version": "~=0.1.0"
"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",
@@ -333,7 +333,7 @@
"full_plugin_name": "dns-hetzner",
"name": "Hetzner",
"package_name": "certbot-dns-hetzner",
"version": "~=1.0.4"
"version": "~=4.0.0"
},
"hetzner-cloud": {
"credentials": "dns_hetzner_cloud_api_token = your_api_token_here",
@@ -381,7 +381,7 @@
"full_plugin_name": "dns-infomaniak",
"name": "Infomaniak",
"package_name": "certbot-dns-infomaniak",
"version": "~=0.2.2"
"version": "~=0.3.3"
},
"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",
@@ -399,6 +399,14 @@
"package_name": "certbot-dns-ionos",
"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": {
"credentials": "dns_ispconfig_username = myremoteuser\ndns_ispconfig_password = verysecureremoteuserpassword\ndns_ispconfig_endpoint = https://localhost:8080",
"dependencies": "",
@@ -599,6 +607,14 @@
"package_name": "certbot-dns-route53",
"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": {
"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": "",
+3 -1
View File
@@ -26,7 +26,9 @@ async function appStart() {
})
.then(() => {
internalCertificate.initTimer();
internalIpRanges.initTimer();
if (IP_RANGES_FETCH_ENABLED) {
internalIpRanges.initTimer();
}
const server = app.listen(3000, () => {
logger.info(`Backend PID ${process.pid} listening on port 3000 ...`);
+24 -17
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import batchflow from "batchflow";
import _ from "lodash";
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 db from "../db.js";
@@ -27,20 +28,18 @@ 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, "$"],
);
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 }])],
);
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}%`],
);
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)) {
@@ -225,12 +224,15 @@ const internalAccessList = {
// 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);
});
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()
const locationHosts = await proxyHostModel
.query()
.where("is_deleted", 0)
.whereIn("id", locationHostIds)
.allowGraph(proxyHostModel.defaultAllowGraph)
@@ -499,6 +501,11 @@ const internalAccessList = {
.sequential()
.each((_i, item, next) => {
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}`);
utils
+319 -18
View File
@@ -24,8 +24,163 @@ const certbotCommand = "certbot";
const certbotLogsDir = "/data/logs";
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 = () => {
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 = {
@@ -75,6 +230,7 @@ const internalCertificate = {
* instance of Certbot is already running.'
*/
let sequence = Promise.resolve();
let renewed = 0;
certificates.forEach((certificate) => {
sequence = sequence.then(() =>
@@ -88,7 +244,11 @@ const internalCertificate = {
token: tokenModel(),
},
{ id: certificate.id },
{ reload: false }, // one reload for the whole batch, below
)
.then(() => {
renewed++;
})
.catch((err) => {
// Don't want to stop the train here, just log the error
logger.error(err.message);
@@ -96,7 +256,11 @@ const internalCertificate = {
);
});
return sequence;
return sequence.then(() => {
if (renewed) {
return internalCertificate.reloadAfterRenew();
}
});
})
.then(() => {
logger.info("Completed SSL cert renew process");
@@ -395,12 +559,35 @@ const internalCertificate = {
*/
delete: async (access, data) => {
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) {
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({
is_deleted: 1,
});
@@ -416,12 +603,47 @@ const internalCertificate = {
});
if (row.provider === "letsencrypt") {
// Revoke the cert
// Revoke the cert. Errors are logged by revokeLetsEncryptSsl.
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;
},
/**
* 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
*
@@ -545,13 +767,15 @@ const internalCertificate = {
/**
* 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.files
* @returns {Promise}
*/
validate: (data) => {
validate: async (access, data) => {
await access.can("certificates:create");
// Put file contents into an object
const files = {};
_.map(data.files, (file, name) => {
@@ -600,27 +824,75 @@ const internalCertificate = {
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") {
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) => {
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, {
id: data.id,
expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
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);
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(" ") : ""}`);
const result = await utils.execFile(certbotCommand, args, adds.opts);
const result = await runCertbot(args, adds.opts);
logger.success(result);
return result;
},
@@ -860,7 +1132,8 @@ const internalCertificate = {
if (hasConfigArg) {
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(
`--${dnsPlugin.full_plugin_name}-propagation-seconds`,
certificate.meta.propagation_seconds.toString(),
@@ -878,7 +1151,12 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
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);
return result;
} finally {
@@ -904,7 +1182,8 @@ const internalCertificate = {
* @param {Number} data.id
* @returns {Promise}
*/
renew: async (access, data) => {
renew: async (access, data, options = {}) => {
const reload = options.reload !== false;
await access.can("certificates:update", data);
const certificate = await internalCertificate.get(access, data);
@@ -930,12 +1209,34 @@ const internalCertificate = {
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;
}
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
* @returns {Promise}
@@ -972,7 +1273,7 @@ const internalCertificate = {
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);
return result;
},
@@ -1056,7 +1357,7 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
try {
const result = await utils.execFile(certbotCommand, args, adds.opts);
const result = await runCertbot(args, adds.opts, certificate.meta.dns_provider, credentials);
logger.info(result);
return result;
} finally {
@@ -1099,7 +1400,7 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
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`);
logger.info(result);
return result;
+5
View File
@@ -54,6 +54,8 @@ const internalDeadHost = {
thisData.advanced_config = "";
}
internalHost.assertAdvancedConfigAllowed(access, thisData);
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
// Add to audit log
@@ -130,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) {
const cert = await internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names,
+103
View File
@@ -1,10 +1,110 @@
import _ from "lodash";
import errs from "../lib/error.js";
import { castJsonIfNeed } from "../lib/helpers.js";
import deadHostModel from "../models/dead_host.js";
import proxyHostModel from "../models/proxy_host.js";
import redirectionHostModel from "../models/redirection_host.js";
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.
* ie: if there is no cert, then force_ssl is off.
@@ -79,6 +179,9 @@ const internalHost = {
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);
responseObject.proxy_hosts = internalHost._getHostsWithDomains(proxyRes, domainNames);
responseObject.total_count += responseObject.proxy_hosts.length;
+122 -21
View File
@@ -4,9 +4,11 @@ import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import _ from "lodash";
import errs from "../lib/error.js";
import { assertAccessListRenderable, assertHostRenderable, isTokenSafe, unsafeValueError } from "../lib/nginx-values.js";
import utils from "../lib/utils.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 __dirname = dirname(__filename);
@@ -39,9 +41,30 @@ const internalNginx = {
return internalNginx.deleteConfig(host_type, host, true);
})
.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
return internalNginx
.test()
@@ -181,7 +204,21 @@ const internalNginx = {
.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;
@@ -209,11 +246,16 @@ const internalNginx = {
* @param {Object} host
* @returns {Promise}
*/
generateConfig: (host_type, host_row) => {
generateConfig: async (host_type, host_row) => {
// Prevent modifying the original object:
const host = JSON.parse(JSON.stringify(host_row));
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));
const renderEngine = utils.getRenderEngine();
@@ -276,29 +318,79 @@ const internalNginx = {
// Set the IPv6 setting for the host
host.ipv6 = internalNginx.ipv6Enabled();
locationsPromise.then(() => {
renderEngine
.parseAndRender(template, host)
.then((config_text) => {
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
debug(logger, "Wrote config:", filename, config_text);
locationsPromise
.then(() => {
renderEngine
.parseAndRender(template, host)
.then((config_text) => {
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
debug(logger, "Wrote config:", filename, config_text);
// Restore locations array
host.locations = origLocations;
// Restore locations array
host.locations = origLocations;
resolve(true);
})
.catch((err) => {
debug(logger, `Could not write ${filename}:`, 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));
});
resolve(true);
})
.catch((err) => {
debug(logger, `Could not write ${filename}:`, 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
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
@@ -311,6 +403,15 @@ const internalNginx = {
debug(logger, "Generating LetsEncrypt Request Config:", certificate);
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) => {
let template = null;
const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
+71 -69
View File
@@ -2,7 +2,7 @@ import _ from "lodash";
import errs from "../lib/error.js";
import { castJsonIfNeed } from "../lib/helpers.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 internalCertificate from "./certificate.js";
import internalHost from "./host.js";
@@ -57,6 +57,8 @@ const internalProxyHost = {
thisData.advanced_config = "";
}
internalHost.assertAdvancedConfigAllowed(access, thisData);
return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
})
.then((row) => {
@@ -83,28 +85,28 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list.[clients,items]"],
});
})
.then(async (row) => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
return row;
});
})
.then((row) => {
// Audit log
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
// Add to audit log
return internalAuditLog
.add(access, {
action: "created",
object_type: "proxy-host",
object_id: row.id,
meta: thisData,
})
.then(() => {
.then(async (row) => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
return row;
});
});
})
.then((row) => {
// Audit log
thisData.meta = _.assign({}, thisData.meta || {}, row.meta);
// Add to audit log
return internalAuditLog
.add(access, {
action: "created",
object_type: "proxy-host",
object_id: row.id,
meta: thisData,
})
.then(() => {
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) {
return internalCertificate
.createQuickCertificate(access, {
@@ -202,24 +207,24 @@ const internalProxyHost = {
});
});
})
.then(() => {
return internalProxyHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"],
})
.then(async (row) => {
if (!row.enabled) {
// No need to add nginx config if host is disabled
return row;
}
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
row.meta = new_meta;
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
.then(() => {
return internalProxyHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"],
})
.then(async (row) => {
if (!row.enabled) {
// No need to add nginx config if host is disabled
return row;
}
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).then((new_meta) => {
row.meta = new_meta;
return _.omit(internalHost.cleanRowCertificateMeta(row), omissions());
});
});
});
});
});
},
/**
@@ -243,7 +248,7 @@ const internalProxyHost = {
.first();
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) {
@@ -323,39 +328,36 @@ const internalProxyHost = {
.then(() => {
return internalProxyHost.get(access, {
id: data.id,
expand: ["certificate", "owner", "access_list"],
expand: ["certificate", "owner", "access_list.[clients,items]"],
});
})
.then(async (row) => {
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
if (row.enabled) {
throw new errs.ValidationError("Host is already enabled");
}
.then(async (row) => {
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
if (row.enabled) {
throw new errs.ValidationError("Host is already enabled");
}
row.enabled = 1;
row.enabled = 1;
await proxyHostModel
.query()
.where("id", row.id)
.patch({
await proxyHostModel.query().where("id", row.id).patch({
enabled: 1,
});
// Configure nginx
await internalNginx.configure(proxyHostModel, "proxy_host", row);
// Configure nginx
await internalNginx.configure(proxyHostModel, "proxy_host", row);
// Add to audit log
await internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
// Add to audit log
await internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
return true;
});
return true;
});
},
/**
@@ -427,7 +429,7 @@ const internalProxyHost = {
.orderBy(castJsonIfNeed("domain_names"), "ASC");
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
@@ -453,18 +455,18 @@ const internalProxyHost = {
*
* @param {Number} user_id
* @param {String} visibility
* @param {Object} [permissionsMeta] the user's permissions meta, as already loaded by access
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
getCount: async (user_id, visibility, permissionsMeta) => {
const query = proxyHostModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") {
query.andWhere("owner_user_id", user_id);
scopeToOwnerOrAllowed(query, user_id, permissionsMeta);
}
return query.first().then((row) => {
return Number.parseInt(row.count, 10);
});
const row = await query.first();
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
// for this optional field.
if (typeof data.advanced_config === "undefined") {
data.advanced_config = "";
if (typeof thisData.advanced_config === "undefined") {
thisData.advanced_config = "";
}
internalHost.assertAdvancedConfigAllowed(access, thisData);
return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
})
.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) {
return internalCertificate
.createQuickCertificate(access, {
+5 -1
View File
@@ -15,7 +15,11 @@ const internalReport = {
const userId = access.token.getUserId(1);
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),
internalStream.getCount(userId, access_data.permission_visibility),
internalDeadHost.getCount(userId, access_data.permission_visibility),
+13 -11
View File
@@ -34,18 +34,20 @@ export default {
.andWhere("is_disabled", 0)
.first();
if (!user) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
// An unknown, disabled or passwordless account goes through the same queries and the same password check
// 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
.query()
.where("user_id", "=", user?.id ?? 0)
.where("type", "=", "password")
.first();
const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first();
if (!auth) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
const valid = await auth.verifyPassword(data.secret);
if (!valid) {
const valid = auth?.secret
? await auth.verifyPassword(data.secret)
: await authModel.verifyDummyPassword(data.secret);
if (!user || !valid) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
+37 -4
View File
@@ -3,6 +3,7 @@ import _ from "lodash";
import errs from "../lib/error.js";
import utils from "../lib/utils.js";
import authModel from "../models/auth.js";
import proxyHostModel from "../models/proxy_host.js";
import userModel from "../models/user.js";
import userPermissionModel from "../models/user_permission.js";
import internalAuditLog from "./audit-log.js";
@@ -444,7 +445,7 @@ const internalUser = {
return internalUser.get(access, { id: data.id });
})
.then((user) => {
if (user.id !== data.id) {
if (Number(user.id) !== Number(data.id)) {
// Sanity check that something crazy hasn't happened
throw new errs.InternalValidationError(
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
@@ -454,21 +455,23 @@ const internalUser = {
return user;
})
.then((user) => {
const permData = _.omit(data, ["id"]);
// Get perms row, patch if it exists
return userPermissionModel
.query()
.where("user_id", user.id)
.first()
.then((existing_auth) => {
.then(async (existing_auth) => {
permData.meta = await internalUser.normalisePermissionMeta(existing_auth, permData);
if (existing_auth) {
// patch
return userPermissionModel
.query()
.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
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data));
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
})
.then((permissions) => {
// Add to Audit Log
@@ -488,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 {Object} data
+10 -3
View File
@@ -13,7 +13,7 @@ import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import { access as logger } from "../logger.js";
import authModel from "../models/auth.js";
import proxyHostModel from "../models/proxy_host.js";
import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
import TokenModel from "../models/token.js";
import userModel from "../models/user.js";
import permsSchema from "./access/permissions.json" with { type: "json" };
@@ -139,7 +139,7 @@ export default function (tokenString) {
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
scopeToOwnerOrAllowed(query, tokenUserId, permissions.meta);
}
const rows = await query;
@@ -213,6 +213,11 @@ export default function (tokenString) {
return {
token: Token,
getPermissions: () => permissions,
getRoles: () => userRoles,
hasRole: (role) => userRoles.includes(role),
isAdmin: () => userRoles.includes("admin") || Token.hasScope("admin"),
/**
*
* @param {Boolean} [allowInternal]
@@ -220,7 +225,8 @@ export default function (tokenString) {
*/
load: async (allowInternal) => {
if (tokenString) {
return await Token.load(tokenString);
await this.init();
return tokenData;
}
allowInternalAccess = allowInternal;
return allowInternal || null;
@@ -255,6 +261,7 @@ export default function (tokenString) {
permission_streams: permissions.streams,
permission_access_lists: permissions.access_lists,
permission_certificates: permissions.certificates,
permission_meta: permissions.meta,
},
};
+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();
};
}
+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);
}
};
+7
View File
@@ -35,6 +35,13 @@ const apiValidator = async (schema, payload /*, description*/) => {
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 err = new errs.ValidationError(message);
err.debug = { validationErrors: validate.errors, payload };
@@ -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 };
+19 -1
View File
@@ -1,6 +1,7 @@
// Objection Docs:
// http://vincit.github.io/objection.js/
import crypto from "node:crypto";
import bcrypt from "bcrypt";
import { Model } from "objection";
import db from "../db.js";
@@ -11,10 +12,15 @@ import User from "./user.js";
Model.knex(db());
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() {
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;
});
}
@@ -60,6 +66,18 @@ class Auth extends Model {
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() {
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;
+4
View File
@@ -24,6 +24,10 @@ class UserPermission extends Model {
static get tableName() {
return "user_permission";
}
static get jsonAttributes() {
return ["meta"];
}
}
export default UserPermission;
+13 -12
View File
@@ -8,42 +8,43 @@
"type": "module",
"scripts": {
"lint": "biome lint",
"test": "node --test test/",
"prettier": "biome format --write .",
"validate-schema": "node validate-schema.js",
"regenerate-config": "node scripts/regenerate-config"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^16.0.2",
"@apidevtools/json-schema-ref-parser": "^16.0.6",
"ajv": "^8.20.0",
"archiver": "^8.0.0",
"batchflow": "^0.4.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0",
"chalk": "5.6.2",
"body-parser": "^2.4.0",
"chalk": "6.0.1",
"compression": "^1.8.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"express": "^5.3.0",
"express-fileupload": "^1.6.0",
"gravatar": "^1.8.2",
"jsonwebtoken": "^9.0.3",
"knex": "3.3.0",
"liquidjs": "10.29.0",
"liquidjs": "10.30.1",
"lodash": "^4.18.1",
"moment": "^2.31.0",
"mysql2": "^3.24.4",
"mysql2": "^3.24.6",
"node-rsa": "^2.0.0",
"objection": "3.1.5",
"otplib": "^13.5.0",
"objection": "4.4.3",
"otplib": "^13.6.0",
"path": "^0.12.7",
"pg": "^8.23.0",
"pg": "^8.23.1",
"proxy-agent": "^8.0.2",
"signale": "1.4.0",
"sqlite3": "^6.0.1",
"temp-write": "^6.0.1"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^13.0.0",
"@biomejs/biome": "^2.5.10",
"@apidevtools/swagger-parser": "^13.1.0",
"@biomejs/biome": "^2.6.6",
"nodemon": "^3.1.14"
},
"signale": {
+8 -2
View File
@@ -62,7 +62,10 @@ router
enum: ["system", "letsencrypt", "host"],
},
host_type: {
anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }],
anyOf: [
{ type: "null" },
{ type: "string", enum: ["proxy", "redirection", "dead", "stream"] },
],
},
host_id: {
anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }],
@@ -76,7 +79,10 @@ router
level: {
anyOf: [
{ type: "null" },
{ type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] },
{
type: "string",
enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"],
},
],
},
search: {
+1 -1
View File
@@ -161,7 +161,7 @@ router
}
try {
const result = await internalCertificate.validate({
const result = await internalCertificate.validate(res.locals.access, {
files: req.files,
});
res.status(200).send(result);
+48 -2
View File
@@ -1,10 +1,56 @@
import express from "express";
import internalToken from "../internal/token.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 { debug, express as logger } from "../logger.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({
caseSensitive: true,
strict: true,
@@ -42,7 +88,7 @@ router
*
* Create a new Token
*/
.post(async (req, res, next) => {
.post(tokenIpRateLimiter, tokenRateLimiter, async (req, res, next) => {
try {
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
const result = await internalToken.getTokenFromEmail(data);
@@ -64,7 +110,7 @@ router
*
* Verify 2FA code and get full token
*/
.post(async (req, res, next) => {
.post(twoFactorRateLimiter, async (req, res, next) => {
try {
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
const result = await internalToken.verify2FA(challenge_token, code);
+1 -1
View File
@@ -260,7 +260,7 @@ router
.put(async (req, res, next) => {
try {
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
payload.id = req.params.user_id;
payload.id = Number.parseInt(req.params.user_id, 10);
const result = await internalUser.setPermissions(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
+4 -3
View File
@@ -84,7 +84,7 @@
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$"
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
},
"example": ["example.com", "www.example.com"]
},
@@ -147,7 +147,7 @@
},
{
"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",
@@ -164,7 +164,8 @@
"properties": {
"username": {
"type": "string",
"minLength": 1
"minLength": 1,
"pattern": "^[^:\\p{Cc}]+$"
},
"password": {
"type": "string"
@@ -31,7 +31,7 @@
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$"
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
},
"example": ["example.com", "www.example.com"]
},
@@ -1,6 +1,7 @@
{
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"visibility": {
"type": "string",
@@ -43,6 +44,25 @@
"description": "Certificates Permissions",
"enum": ["hidden", "view", "manage"],
"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",
"minLength": 1,
"maxLength": 255,
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\$/]+(?:/[^\\s\\p{Cc};{}\"'#\\\\]*)?$",
"example": "127.0.0.1"
},
"forward_port": {
@@ -108,7 +109,8 @@
},
"path": {
"type": "string",
"minLength": 1
"minLength": 1,
"pattern": "^(?!(?:=|~\\*?|\\^~)$)(?:(?:=|~\\*?|\\^~) +)?(?:\"(?:[^\"\\\\\\p{Cc}]|\\\\[^\\p{Cc}])+\"|(?:[^\\s\\p{Cc};{}\"'#\\\\]|\\\\[^\\s\\p{Cc}])+)$"
},
"forward_scheme": {
"$ref": "#/properties/forward_scheme"
@@ -120,7 +122,8 @@
"$ref": "#/properties/forward_port"
},
"forward_path": {
"type": "string"
"type": "string",
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\]*$"
},
"advanced_config": {
"type": "string"
@@ -57,7 +57,7 @@
"forward_domain_name": {
"description": "Domain Name",
"type": "string",
"pattern": "^(?:[^.*]+\\.?)+[^.]$",
"pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
"example": "jc21.com"
},
"preserve_path": {
+1 -1
View File
@@ -39,7 +39,7 @@
{
"description": "Domain Name",
"type": "string",
"pattern": "^(?:[^.*]+\\.?)+[^.]$",
"pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\$/]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
"example": "example.com"
},
{
@@ -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",
"proxy_hosts": "manage",
"redirection_hosts": "hidden",
"streams": "hidden"
"streams": "hidden",
"meta": {
"proxy_host_ids": [1]
}
}
}
}
+5
View File
@@ -1,4 +1,5 @@
{% if access_list_id > 0 %}
{% if access_list %}
{% if access_list.items.length > 0 %}
# Authorization
auth_basic "Authorization required";
@@ -24,4 +25,8 @@
{% else %}
satisfy all;
{% endif %}
{% else %}
# Access List #{{ access_list_id }} could not be loaded: deny rather than serve unrestricted
deny all;
{% endif %}
{% endif %}
+5 -1
View File
@@ -2,7 +2,11 @@
{% if ssl_forced == 1 or ssl_forced == true %}
{% if hsts_enabled == 1 or hsts_enabled == true %}
# 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 %}
+5 -2
View File
@@ -1,3 +1,6 @@
map $scheme $hsts_header {
https "max-age=63072000;{% if hsts_subdomains == 1 or hsts_subdomains == true -%} includeSubDomains;{% endif %} preload";
map $scheme $hsts_header_plain {
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 }} {
{{ 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-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
+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");
}
});
});
+294 -307
View File
@@ -2,20 +2,12 @@
# yarn lockfile v1
"@apidevtools/json-schema-ref-parser@15.3.6":
version "15.3.6"
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.6.tgz#9e6360264d90b209eb09f9e4dc9a32316d86f4ea"
integrity sha512-oPFDSviRrreUmCZn09LCD4S9QQa6j7zfEwBM1pkGa/kXY0O4sXsEXn4emLP/Tt6tik2+BtgwKk2eldAeOWZ2Rw==
"@apidevtools/json-schema-ref-parser@^16.0.3", "@apidevtools/json-schema-ref-parser@^16.0.6":
version "16.0.6"
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.6.tgz#30b21ddbaa6ddd1161d1534829002dab0d720980"
integrity sha512-cJXhmvqCn4Ud7X7d4L/BtAiJRIrLNiuIZ48ZTuDfN0mYejVXxdzMGR07teW6eKzLwcN0FwMi7gykE2Kr09LS1g==
dependencies:
js-yaml "^4.2.0"
"@apidevtools/json-schema-ref-parser@^16.0.2":
version "16.0.2"
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-16.0.2.tgz#21f55458a55d1b02bc62f19c731913d8e74976b3"
integrity sha512-oCwWUyRdMtt/hnp6ONPudFZzF7tSqiHy46lLx5VsnsLTacs8TSU558VPAiOueFJ8WddCH30PINWDA8ByxhGHmQ==
dependencies:
js-yaml "^5.4.1"
undici "^8.10.2"
js-yaml "^5.4.2"
"@apidevtools/openapi-schemas@^2.1.0":
version "2.1.0"
@@ -27,72 +19,84 @@
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267"
integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==
"@apidevtools/swagger-parser@^13.0.0":
version "13.0.0"
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.0.0.tgz#a8363c086cc2ae0005b270cfc241f353a741e49c"
integrity sha512-TcvnmBzJD7DBREkc/2qaxMqbKpttOaHhOFsYRYKdYAv6HGY+iNlWY2IUA209C2/NmvL9z8RBZhkrESqvGkjqmw==
"@apidevtools/swagger-parser@^13.1.0":
version "13.1.0"
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-13.1.0.tgz#efc8ffcc573838e715d0bce83d917b79636ca172"
integrity sha512-dkCuqCWh5U7u0aurmmc/qvel2KeKDxHTwVtUdYonndIpD7mXV6AVaWNdvaja2HAy628z7FT/Qdz8sCgzwhgwKg==
dependencies:
"@apidevtools/json-schema-ref-parser" "15.3.6"
"@apidevtools/json-schema-ref-parser" "^16.0.3"
"@apidevtools/openapi-schemas" "^2.1.0"
"@apidevtools/swagger-methods" "^3.0.2"
"@types/json-schema" "^7.0.15"
ajv "^8.17.1"
ajv "^8.20.0"
ajv-draft-04 "^1.0.0"
call-me-maybe "^1.0.2"
"@biomejs/biome@^2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.5.10.tgz#c2a42fc6f9c7d9e12c1f77f63dfb6b5489c497b7"
integrity sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==
"@biomejs/biome@^2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/biome/-/biome-2.6.6.tgz#b40e03ff3dbe070963dd6332171ffffeb3cf7619"
integrity sha512-NCgVJRyrU1+hsW1ILNaRqNAb9fRL4n49bwyef+wjVJQ488/bXbp0BfnNqTTExkiNQT3ugkgYKR1vK8NxsjlNSQ==
optionalDependencies:
"@biomejs/cli-darwin-arm64" "2.5.10"
"@biomejs/cli-darwin-x64" "2.5.10"
"@biomejs/cli-linux-arm64" "2.5.10"
"@biomejs/cli-linux-arm64-musl" "2.5.10"
"@biomejs/cli-linux-x64" "2.5.10"
"@biomejs/cli-linux-x64-musl" "2.5.10"
"@biomejs/cli-win32-arm64" "2.5.10"
"@biomejs/cli-win32-x64" "2.5.10"
"@biomejs/cli-android-arm64" "2.6.6"
"@biomejs/cli-android-x64" "2.6.6"
"@biomejs/cli-darwin-arm64" "2.6.6"
"@biomejs/cli-darwin-x64" "2.6.6"
"@biomejs/cli-linux-arm64" "2.6.6"
"@biomejs/cli-linux-arm64-musl" "2.6.6"
"@biomejs/cli-linux-x64" "2.6.6"
"@biomejs/cli-linux-x64-musl" "2.6.6"
"@biomejs/cli-win32-arm64" "2.6.6"
"@biomejs/cli-win32-x64" "2.6.6"
"@biomejs/cli-darwin-arm64@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz#f1453a7f2e63f228a34645df1c33962f22a6c2a8"
integrity sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==
"@biomejs/cli-android-arm64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-android-arm64/-/cli-android-arm64-2.6.6.tgz#dd4a994b35837a521459fea4f4f27a194c47258d"
integrity sha512-bicnqNNxEX7pjkDQaptYvPRIWDfgXJLl/Ok7iZjC+F1AhfReor3FSS7SgPYnOvUy/jf++5U/KYN4T8OKH5rrNA==
"@biomejs/cli-darwin-x64@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz#e21041e7bfab01cd453f609b940bf613713d0e24"
integrity sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==
"@biomejs/cli-android-x64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-android-x64/-/cli-android-x64-2.6.6.tgz#1804e55a64e9e0b11f56492863c98fd80f1a4028"
integrity sha512-N443kDSElF4kDMKjarsL+ymj/ewLA5R9VAdawA7fJ7pwgN3JVDZp0K+xhKBglH8EwsKi2+K8xC6WxXSkgziFfw==
"@biomejs/cli-linux-arm64-musl@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz#57fc8aee25c08ca6c14526ccc44e282e7ba96eb6"
integrity sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==
"@biomejs/cli-darwin-arm64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.6.6.tgz#dd6be6a1d8457a465c555311c909e6aa81f1c9ea"
integrity sha512-/vSAqWx3by3p8w5ew+HVJDgguuaTK3U6Fb5gk/oDUH21iWOopm3gOlhT6tZfy5mTaM5R4CVc3uFWkxBtEDrKyw==
"@biomejs/cli-linux-arm64@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz#aef789a71e5aa86f5a0e1aff6cb4c1b757ec5be1"
integrity sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==
"@biomejs/cli-darwin-x64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.6.6.tgz#fb2609d97f4ead53dbff30507a42be1fe56a1e9c"
integrity sha512-lLdOAJKbrAoDs+3GvTyVmMW345D9IhrIE3GgoL2eeHwO1boN4VhrSlALtFYsP0p+Jy4udUp4bTguij0qjKrH8w==
"@biomejs/cli-linux-x64-musl@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz#1aebe9f928dc72dc3c112e60f1211a4d646990c4"
integrity sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==
"@biomejs/cli-linux-arm64-musl@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.6.6.tgz#28412c5039466b87c9d3a59176994a45422d60a4"
integrity sha512-rw2Vsoi/WZGDyRjMXx9KZH9zsHe79LIzkdWHkLqsIkeKZm6qiGxtiIApxn7cmJSoEPaSvHaH5pRj1FhTcyL/XA==
"@biomejs/cli-linux-x64@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz#a82b414abf5d9668fb035ee50a710bf78a7c5fbf"
integrity sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==
"@biomejs/cli-linux-arm64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.6.6.tgz#04ced06069af3c7a4d20da44c53c21e70ce42176"
integrity sha512-UzJdUCg9dbEGpZOuZRyehEPrnwKjx9z4Um7sWLG5x6txu7UjeWdJZuBXVGlyAdD39w2Hx0nZeK1jKC4NFUTCNQ==
"@biomejs/cli-win32-arm64@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz#db671cf67d7040a374e8cdda5aebe73c3f4ffba0"
integrity sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==
"@biomejs/cli-linux-x64-musl@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.6.6.tgz#78109fb485dcbce9eaf5977e2a70658696dc6405"
integrity sha512-DiKejs+GnFbOVQsBnukUpiB+T13tqUDFGwwjsTq51vatAsaflzFzVD0BOgjVUGRrqKgE0K0Xf+wNtkcmdN2chw==
"@biomejs/cli-win32-x64@2.5.10":
version "2.5.10"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz#3bdeaf5856f21aec6b6c50a7bca5464b9cda5623"
integrity sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==
"@biomejs/cli-linux-x64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.6.6.tgz#b5315ccaa511a2bfb103db50013a266887d793bb"
integrity sha512-l8/aPXc1cz8VpUyd4hb5Pm5zx+X3voQBks4aLYbp5pC8KSchuHyvqDfY5xax39qCHomI/2xn6NKEot+zebG38g==
"@biomejs/cli-win32-arm64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.6.6.tgz#41c0a80a9b98e9b879fb2413a5faf0775a526f07"
integrity sha512-DjVXwQAQeHrO1JEZAEus54k40Tnh4glyU3VFLcDBKwoz0xnxCEnqw6TCMXoujcQMQynWd3AY8bLrbR1o0XNqow==
"@biomejs/cli-win32-x64@2.6.6":
version "2.6.6"
resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.6.6.tgz#f3e5230d59a48ac1da6ab951ad5be364591fb64c"
integrity sha512-B7KMG26A4I9lAuxwBGMmiVcjMGAeJRoz+pzFt9brRvOAsIrgOu8ptJld5rUWui6tM1uiJwxeJhhDPuAiZtxXdw==
"@isaacs/fs-minipass@^4.0.0":
version "4.0.1"
@@ -101,60 +105,60 @@
dependencies:
minipass "^7.0.4"
"@noble/hashes@^2.2.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.3.0.tgz#505fd39c3134a37e67c8c4e6c6049a496154879c"
integrity sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==
"@noble/hashes@^2.2.0", "@noble/hashes@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.4.0.tgz#e6a3e98edbed3c8659b28bb8f9b18ca02ec8ea4b"
integrity sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==
"@otplib/core@13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.5.0.tgz#629a7fef5b6394f34ff55213b936223b368796a4"
integrity sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==
"@otplib/core@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-13.6.0.tgz#f939a821604467b87be29b465cb54e2f270c3789"
integrity sha512-TL6/FKKzR55Vr7zo3YGFtPMCSV3SpYPtuvE3T1XcwjFczD9pHU4dYYQBl0AcNvGcqAt4/fPqhZEFfB2OBcnlPA==
"@otplib/hotp@13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.5.0.tgz#2832cc6e52f3d037428f094177b4779babe19aa5"
integrity sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==
"@otplib/hotp@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@otplib/hotp/-/hotp-13.6.0.tgz#b9c0e2ed27f63dff9bb704c881bb006f54a2d4b4"
integrity sha512-oPpaQBkAYjBxAs+FWdGNumsEwWJhIZVcsv0vEi+w4OzzLQ6UDZ2qXZNSeF9NljTj6/8d4e1Z4o6RgJa3aIxAOA==
dependencies:
"@otplib/core" "13.5.0"
"@otplib/uri" "13.5.0"
"@otplib/core" "13.6.0"
"@otplib/uri" "13.6.0"
"@otplib/plugin-base32-scure@13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz#ba2f329aeea3ed4df3c4e21ce2e9c2e8c9aaed4f"
integrity sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==
"@otplib/plugin-base32-scure@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.6.0.tgz#73509fbd9946642dd78f2990190f5cdb86aa0f81"
integrity sha512-s6uMbj4MBebvAPnBns1y2RhQ765/U04qHlZmFLV02dlD1S06pU46g/7tHX0vkCx5Pnty723loXto/PM5gFEEdg==
dependencies:
"@otplib/core" "13.5.0"
"@scure/base" "^2.2.0"
"@otplib/core" "13.6.0"
"@scure/base" "^2.4.0"
"@otplib/plugin-crypto-noble@13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz#820227beb159090a7448ce7245a4e615b5579f0e"
integrity sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==
"@otplib/plugin-crypto-noble@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.6.0.tgz#c2702906e6cc128cf8b32fafd35163683a02394e"
integrity sha512-m/8mOhtIhsr4zLtb9MlLI6+w7ZNiKSFnT8Dv7eHR9TCyf2Sxva5e+MbV0wONmF7ZLuvo9F3DtzfPjc748PhJcw==
dependencies:
"@noble/hashes" "^2.2.0"
"@otplib/core" "13.5.0"
"@noble/hashes" "^2.4.0"
"@otplib/core" "13.6.0"
"@otplib/totp@13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.5.0.tgz#48a60807457726ea34f1d1185f994120652e4468"
integrity sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==
"@otplib/totp@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@otplib/totp/-/totp-13.6.0.tgz#fa309ced2594e83b060d9b746d0218d2957153a3"
integrity sha512-bMlq0MaUaQE3lOSH352Mrzcwpau5pMf7pMLROS+llk2evpC/sE7SHl9ajBl+PiPnGGb6LGYEjDvKqX8caXkGdw==
dependencies:
"@otplib/core" "13.5.0"
"@otplib/hotp" "13.5.0"
"@otplib/uri" "13.5.0"
"@otplib/core" "13.6.0"
"@otplib/hotp" "13.6.0"
"@otplib/uri" "13.6.0"
"@otplib/uri@13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.5.0.tgz#d2da470be24f872bb1c24717bcdd2e5c122e3ce3"
integrity sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==
"@otplib/uri@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@otplib/uri/-/uri-13.6.0.tgz#c26e70293ccd4bc8f1d6e46c051e2b70bf1a56dc"
integrity sha512-CiLFMONTpCi3ara6yQQX3nlpYhafCALKJweA7+7Z+aDkRrW3QmD5a/9thKmQ0cGp2W70AMG/6BSTy82h74LHRw==
dependencies:
"@otplib/core" "13.5.0"
"@otplib/core" "13.6.0"
"@scure/base@^2.2.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.3.0.tgz#fc7acb5b7084c53e4e2537d83fa72cddac7143fd"
integrity sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==
"@scure/base@^2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.4.0.tgz#61e4ed7d4447afc247f2bff9aeb8fca53267db86"
integrity sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==
"@types/json-schema@^7.0.15":
version "7.0.15"
@@ -191,10 +195,10 @@ ajv-draft-04@^1.0.0:
resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8"
integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==
ajv-formats@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520"
integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==
ajv-formats@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578"
integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==
dependencies:
ajv "^8.0.0"
@@ -250,10 +254,10 @@ archiver@^8.0.0:
tar-stream "^3.0.0"
zip-stream "^7.0.2"
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
argparse@^3.0.3:
version "3.0.4"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-3.0.4.tgz#ba802dd75fdc3d5bdcb4339b713d62c6c5d4565d"
integrity sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==
ast-types@^0.13.4:
version "0.13.4"
@@ -272,25 +276,30 @@ aws-ssl-profiles@^1.1.2:
resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641"
integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
b4a@^1.6.4, b4a@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4"
integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==
b4a@^1.6.4, b4a@^1.8.1, b4a@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.9.0.tgz#6336313b6e76b299c397afa68676ec6ebc788845"
integrity sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==
balanced-match@^4.0.2:
version "4.0.4"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
bare-events@^2.5.4, bare-events@^2.7.0:
version "2.9.1"
resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.1.tgz#5c86616966343bcb03a1b3155feab253eadbf349"
integrity sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==
bare-buffer@^3.0.0:
version "3.7.1"
resolved "https://registry.yarnpkg.com/bare-buffer/-/bare-buffer-3.7.1.tgz#dcd2fb7907cfe7df0cf18cb752a04ae2a41140bc"
integrity sha512-cIjZnSO+y89ykVX9x96OVnA6tGORBn7vGJ/e3PCbBHMEyfMNG/ixzOS0lLa6saMlWcs69bSKjxFyDwjK152OMg==
bare-fs@^4.5.5:
version "4.8.0"
resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.0.tgz#8c0f916833ae11e6d9d347f9e0a7f46f7375bb8f"
integrity sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==
bare-events@^2.5.4, bare-events@^2.7.0:
version "2.9.2"
resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.2.tgz#01d377b64c3c7167b28b52da7add3221aecf2d7f"
integrity sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==
bare-fs@^4.8.2:
version "4.8.4"
resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.4.tgz#f342288e7e5d9d8e79917078e1f45950507828c5"
integrity sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==
dependencies:
bare-events "^2.5.4"
bare-path "^3.0.0"
@@ -299,24 +308,25 @@ bare-fs@^4.5.5:
fast-fifo "^1.3.2"
bare-path@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.1.tgz#d4a207c088609b4663a7556a46a97342398bf7e2"
integrity sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==
version "3.1.2"
resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.2.tgz#67261bc74a9ba0105ba139b8638f218c09e8d82e"
integrity sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==
bare-stream@^2.6.4:
version "2.13.3"
resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.3.tgz#f6186c7cbb4bbf53a4560f35e48b16373ba51ce6"
integrity sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==
version "2.13.4"
resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.4.tgz#61d448a268d1efd992103aae6bd729332f278ac0"
integrity sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==
dependencies:
b4a "^1.8.1"
streamx "^2.25.0"
teex "^1.0.1"
bare-url@^2.2.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.2.tgz#e8dafaefbdb56891a0fbc2b0e1c051296d241cd6"
integrity sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==
version "2.5.6"
resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.6.tgz#7f7a96c459bd432bec43f797c0afca1325cb9b5f"
integrity sha512-iqkXHkW65De+BT14nHLiBFbNuJ/DdMrZaVNJ1IXPctpK6b1olUJ5Qmy8QBvwROajTiGztso2n82kFEvCsNG7Rg==
dependencies:
bare-buffer "^3.0.0"
bare-path "^3.0.0"
base64-js@^1.3.1:
@@ -375,25 +385,25 @@ blueimp-md5@^2.16.0:
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
body-parser@^2.2.1, body-parser@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437"
integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==
body-parser@^2.3.0, body-parser@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.4.0.tgz#337a76249270f83e450ad385a9606ca3b4a75c06"
integrity sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==
dependencies:
bytes "^3.1.2"
content-type "^2.0.0"
content-type "^2.1.0"
debug "^4.4.3"
http-errors "^2.0.1"
iconv-lite "^0.7.2"
iconv-lite "^0.7.3"
on-finished "^2.4.1"
qs "^6.15.2"
qs "^6.16.1"
raw-body "^3.0.2"
type-is "^2.1.0"
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
version "5.0.12"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec"
integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==
dependencies:
balanced-match "^4.0.2"
@@ -468,10 +478,10 @@ camelcase@^5.0.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
chalk@5.6.2:
version "5.6.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea"
integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==
chalk@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-6.0.1.tgz#077d4667ad6e608c9687782caed3d534d7f9abc3"
integrity sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==
chalk@^2.3.2:
version "2.4.2"
@@ -582,15 +592,10 @@ compression@^1.8.2:
safe-buffer "5.2.1"
vary "~1.1.2"
content-disposition@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17"
integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==
content-type@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
content-disposition@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-2.0.1.tgz#88a75c7a52ef9c407c96035fe83a66939c2a4dc9"
integrity sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==
content-type@^2.0.0, content-type@^2.1.0:
version "2.1.0"
@@ -630,11 +635,6 @@ data-uri-to-buffer@8.0.0:
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz#c642ffbc96d8fae76cccecbad43cbddd7818c785"
integrity sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==
db-errors@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/db-errors/-/db-errors-0.2.3.tgz#a6a38952e00b20e790f2695a6446b3c65497ffa2"
integrity sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -729,9 +729,9 @@ emoji-regex@^8.0.0:
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
encodeurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
version "2.0.1"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.1.tgz#f268548822bd87b5b4fdce83c56388c6d33c9de6"
integrity sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==
end-of-stream@^1.1.0, end-of-stream@^1.4.1:
version "1.4.5"
@@ -847,22 +847,22 @@ exponential-backoff@^3.1.1:
resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6"
integrity sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==
express-fileupload@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.5.2.tgz#4da70ba6f2ffd4c736eab0776445865a9dbd9bfa"
integrity sha512-wxUJn2vTHvj/kZCVmc5/bJO15C7aSMyHeuXYY3geKpeKibaAoQGcEv5+sM6nHS2T7VF+QHS4hTWPiY2mKofEdg==
express-fileupload@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/express-fileupload/-/express-fileupload-1.6.0.tgz#fbc7035f090a554ae54005b1d8a7bb46c8440a62"
integrity sha512-jIVP7PB+uNLrsglQUKzlK8PjxGkzPy3h0kH0XKpoxQnoT5sPNnHvfWnMMkLoP36LJS5Y8NkkdvkAYQnGCB6tdQ==
dependencies:
busboy "^1.6.0"
express@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04"
integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==
express@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/express/-/express-5.3.0.tgz#ab72dace89c50c8417bd3f865dad0b4fd1ca1c73"
integrity sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==
dependencies:
accepts "^2.0.0"
body-parser "^2.2.1"
content-disposition "^1.0.0"
content-type "^1.0.5"
body-parser "^2.3.0"
content-disposition "^2.0.1"
content-type "^2.0.0"
cookie "^0.7.1"
cookie-signature "^1.2.1"
debug "^4.4.0"
@@ -878,14 +878,14 @@ express@^5.2.1:
on-finished "^2.4.1"
once "^1.4.0"
parseurl "^1.3.3"
proxy-addr "^2.0.7"
qs "^6.14.0"
proxy-addr "^2.0.8"
qs "^6.16.0"
range-parser "^1.2.1"
router "^2.2.0"
send "^1.1.0"
serve-static "^2.2.0"
statuses "^2.0.1"
type-is "^2.0.1"
type-is "^2.1.0"
vary "^1.1.2"
fast-deep-equal@^3.1.3:
@@ -893,15 +893,15 @@ fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-fifo@^1.2.0, fast-fifo@^1.3.2:
fast-fifo@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
fast-uri@^3.0.1:
version "3.1.7"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a"
integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==
version "3.1.9"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.9.tgz#f1deba822b1d6ac79da2f860e872a63470cff203"
integrity sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==
fdir@^6.5.0:
version "6.5.0"
@@ -1076,7 +1076,7 @@ has-symbols@^1.1.0:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
hasown@^2.0.2, hasown@^2.0.3:
hasown@^2.0.2, hasown@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
@@ -1112,7 +1112,7 @@ https-proxy-agent@9.1.0:
debug "^4.3.4"
proxy-agent-negotiate "1.1.0"
iconv-lite@^0.7.2, iconv-lite@^0.7.3, iconv-lite@~0.7.0:
iconv-lite@^0.7.3, iconv-lite@~0.7.0:
version "0.7.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f"
integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==
@@ -1150,9 +1150,9 @@ interpret@^2.2.0:
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
ip-address@^10.1.1:
version "10.5.0"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.5.0.tgz#fcd6d7dfe9e68416b7cb71afe9bc960b3e38c13b"
integrity sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==
version "10.7.6"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.6.tgz#e3c06961cedf352494c06c916c696cfdb11068dd"
integrity sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==
ipaddr.js@1.9.1:
version "1.9.1"
@@ -1171,12 +1171,12 @@ is-binary-path@~2.1.0:
dependencies:
binary-extensions "^2.0.0"
is-core-module@^2.16.1:
version "2.16.2"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082"
integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==
is-core-module@^2.18.0:
version "2.18.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.18.0.tgz#8a988af8d3729fb6bbcaf025bf820cfe46b95f4c"
integrity sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==
dependencies:
hasown "^2.0.3"
hasown "^2.0.4"
is-extglob@^2.1.1:
version "2.1.1"
@@ -1225,19 +1225,12 @@ isexe@^4.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-4.0.0.tgz#48f6576af8e87a18feb796b7ed5e2e5903b43dca"
integrity sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==
js-yaml@^4.2.0:
version "4.3.2"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
js-yaml@^5.4.2:
version "5.5.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.5.0.tgz#ec843f09ffb2a54b6758a41eee109a1815d16442"
integrity sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==
dependencies:
argparse "^2.0.1"
js-yaml@^5.4.1:
version "5.4.2"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-5.4.2.tgz#eebc7a57e0bbe1d6b6d9416a72e7463082c7da85"
integrity sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==
dependencies:
argparse "^2.0.1"
argparse "^3.0.3"
json-parse-better-errors@^1.0.1:
version "1.0.2"
@@ -1309,10 +1302,10 @@ lazystream@^1.0.0:
dependencies:
readable-stream "^2.0.5"
liquidjs@10.29.0:
version "10.29.0"
resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.29.0.tgz#ee061890ccf0da26be377aad4f02bd32da0140d8"
integrity sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==
liquidjs@10.30.1:
version "10.30.1"
resolved "https://registry.yarnpkg.com/liquidjs/-/liquidjs-10.30.1.tgz#aa8c24535d638f88679a22cd8042c38b3ca61dc6"
integrity sha512-P1Iz14WUHZdfbOYwHOIhtl/iAWIOYk1ks5l9rPZlDM6vZo+6BLeYIuWHAPkIz+U8Mm8HKZKITIrjX7DQJO2fqA==
dependencies:
commander "^10.0.0"
@@ -1392,9 +1385,9 @@ lru-cache@^7.14.1:
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
lru.min@^1.1.0, lru.min@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b"
integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==
version "1.1.5"
resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.5.tgz#bd32483f4e342261d0eb011b06d2219e6b5ef818"
integrity sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==
math-intrinsics@^1.1.0:
version "1.1.0"
@@ -1477,10 +1470,10 @@ ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
mysql2@^3.24.4:
version "3.24.4"
resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.4.tgz#7752de0cf10b1d2984103bdbc60f696c60756f31"
integrity sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==
mysql2@^3.24.6:
version "3.24.6"
resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.24.6.tgz#8e9471f0ceca917599ac021356bb24f08f168b3c"
integrity sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==
dependencies:
aws-ssl-profiles "^1.1.2"
generate-function "^2.3.1"
@@ -1520,9 +1513,9 @@ netmask@^2.0.2:
integrity sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==
node-abi@^3.3.0:
version "3.94.0"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.94.0.tgz#007181ed0d1b56ae9670ea6c084d2bf83538405f"
integrity sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==
version "3.101.0"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.101.0.tgz#3c99775cb0a4ddf4f3f0dad2a9ad940d67ef0052"
integrity sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==
dependencies:
semver "^7.3.5"
@@ -1592,19 +1585,18 @@ object-inspect@^1.13.3, object-inspect@^1.13.4:
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
objection@3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/objection/-/objection-3.1.5.tgz#53c32f6b6cba2958bc28cf723de96c2676da8286"
integrity sha512-Hx/ipAwXSuRBbOMWFKtRsAN0yITafqXtWB4OT4Z9wED7ty1h7bOnBdhLtcNus23GwLJqcMsRWdodL2p5GwlnfQ==
objection@4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/objection/-/objection-4.4.3.tgz#aa965ccb29cfd97b3717539ac73f9095c94a5cbf"
integrity sha512-Ie1jBkoQvzCT7SBuh1nNl/c2gC6lACOy6vY7dygWyZr70bP1Um660XbxCLhXAQ9Uqwok4LWLjzsnKgNd7JpV6w==
dependencies:
ajv "^8.17.1"
ajv-formats "^2.1.1"
db-errors "^0.2.3"
ajv-formats "^3.0.1"
on-finished@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
version "2.4.2"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.2.tgz#6932b04b95bf6a4828201db10af75fe5c9c38b73"
integrity sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==
dependencies:
ee-first "1.1.1"
@@ -1620,17 +1612,17 @@ once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"
otplib@^13.5.0:
version "13.5.0"
resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.5.0.tgz#78b5d0d9edf83078d7fe37243bbcdd96d7a65d37"
integrity sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==
otplib@^13.6.0:
version "13.6.0"
resolved "https://registry.yarnpkg.com/otplib/-/otplib-13.6.0.tgz#aa462e7e2c0370278666a608144c3194c7b2f7fe"
integrity sha512-PCx6+14PYndCRk0G3JuJHmlExzENs43gafqMUVmSFmzMbEweVhBiYcBCL8B32ZMEDPZA4OsMxFEeAob1H+YFZg==
dependencies:
"@otplib/core" "13.5.0"
"@otplib/hotp" "13.5.0"
"@otplib/plugin-base32-scure" "13.5.0"
"@otplib/plugin-crypto-noble" "13.5.0"
"@otplib/totp" "13.5.0"
"@otplib/uri" "13.5.0"
"@otplib/core" "13.6.0"
"@otplib/hotp" "13.6.0"
"@otplib/plugin-base32-scure" "13.6.0"
"@otplib/plugin-crypto-noble" "13.6.0"
"@otplib/totp" "13.6.0"
"@otplib/uri" "13.6.0"
p-limit@^1.1.0:
version "1.3.0"
@@ -1733,20 +1725,20 @@ path@^0.12.7:
process "^0.11.1"
util "^0.10.3"
pg-cloudflare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz#4b4c20e6d8ae531d400730f4804571a8d62f1497"
integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==
pg-cloudflare@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz#0fc904c9a8b3ca3a4f71e16bcc9171224ecbb2a8"
integrity sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==
pg-connection-string@2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475"
integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==
pg-connection-string@^2.14.0:
version "2.14.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9"
integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==
pg-connection-string@^2.14.1:
version "2.14.1"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.1.tgz#d9529fe2497c0cac8a9f5f6f059679c52f1bd964"
integrity sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==
pg-int8@1.0.1:
version "1.0.1"
@@ -1758,10 +1750,10 @@ pg-pool@^3.14.0:
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90"
integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
pg-protocol@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305"
integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==
pg-protocol@^1.16.1:
version "1.16.1"
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.1.tgz#87a3951b57002682d63166c029859bd834e64bf1"
integrity sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==
pg-types@2.2.0:
version "2.2.0"
@@ -1774,18 +1766,18 @@ pg-types@2.2.0:
postgres-date "~1.0.4"
postgres-interval "^1.1.0"
pg@^8.23.0:
version "8.23.0"
resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.0.tgz#5c2026d32bd0cb4fbd9196bac1ecf5ae66607180"
integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==
pg@^8.23.1:
version "8.23.1"
resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.1.tgz#e4848051a04aa2f968fd5671d075f89e5e7918b9"
integrity sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==
dependencies:
pg-connection-string "^2.14.0"
pg-connection-string "^2.14.1"
pg-pool "^3.14.0"
pg-protocol "^1.16.0"
pg-protocol "^1.16.1"
pg-types "2.2.0"
pgpass "1.0.5"
optionalDependencies:
pg-cloudflare "^1.4.0"
pg-cloudflare "^1.4.1"
pgpass@1.0.5:
version "1.0.5"
@@ -1800,9 +1792,9 @@ picomatch@^2.0.4, picomatch@^2.2.1:
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
version "4.0.7"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f"
integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==
pify@^3.0.0:
version "3.0.0"
@@ -1872,10 +1864,10 @@ process@^0.11.1, process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
proxy-addr@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
proxy-addr@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.8.tgz#624e760fd8ab06b1b8320c2f8506d4422cf8e932"
integrity sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==
dependencies:
forwarded "0.2.0"
ipaddr.js "1.9.1"
@@ -1917,10 +1909,10 @@ pump@^3.0.0:
end-of-stream "^1.1.0"
once "^1.3.1"
qs@^6.14.0, qs@^6.15.2:
version "6.16.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd"
integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==
qs@^6.16.0, qs@^6.16.1:
version "6.17.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.17.1.tgz#43ac71d29ca0e9d0eed16a218e35b6cde5628e81"
integrity sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==
dependencies:
es-define-property "^1.0.1"
side-channel "^1.1.1"
@@ -1983,9 +1975,9 @@ readable-stream@^3.1.1, readable-stream@^3.4.0:
util-deprecate "^1.0.1"
readable-stream@^4.0.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91"
integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==
version "4.8.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.8.0.tgz#22af894e6d60cb0064410fc376367ec4ce470adb"
integrity sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==
dependencies:
abort-controller "^3.0.0"
buffer "^6.0.3"
@@ -2035,12 +2027,12 @@ resolve-from@^5.0.0:
integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
resolve@^1.20.0:
version "1.22.12"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f"
integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==
version "1.22.14"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.14.tgz#302388cd2d7731a100db1dedce7b050f74ba38f7"
integrity sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA==
dependencies:
es-errors "^1.3.0"
is-core-module "^2.16.1"
is-core-module "^2.18.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
@@ -2071,9 +2063,9 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
semver@^7.3.5, semver@^7.5.3, semver@^7.5.4:
version "7.8.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
version "7.8.6"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.6.tgz#67c0cda133a3ceb6f70b803e67625bf969000424"
integrity sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==
send@^1.1.0, send@^1.2.0:
version "1.2.1"
@@ -2197,9 +2189,9 @@ socks-proxy-agent@10.1.0:
socks "^2.8.3"
socks@^2.8.3:
version "2.8.9"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752"
integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==
version "2.8.10"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.10.tgz#23aab4dccc1fdae110aaf4fa51cd190552432dc5"
integrity sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==
dependencies:
ip-address "^10.1.1"
smart-buffer "^4.2.0"
@@ -2215,9 +2207,9 @@ split2@^4.1.0:
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
sql-escaper@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.1.tgz#fbaee511294d1f7da92fb68ca1faee2255839c7b"
integrity sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==
version "1.5.2"
resolved "https://registry.yarnpkg.com/sql-escaper/-/sql-escaper-1.5.2.tgz#5b59840dc4034bd23a9b7d00501ba947a90364b7"
integrity sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==
sqlite3@^6.0.1:
version "6.0.1"
@@ -2241,10 +2233,10 @@ streamsearch@^1.1.0:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0:
version "2.28.0"
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.0.tgz#035ab56057b7ed2211b51d532e6973f0f99fbf11"
integrity sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==
streamx@^2.12.5, streamx@^2.25.0, streamx@^2.28.1:
version "2.28.1"
resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.1.tgz#376cd42a089505a69bec0efdb5189aa45586141f"
integrity sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==
dependencies:
events-universal "^1.0.0"
fast-fifo "^1.3.2"
@@ -2324,14 +2316,14 @@ tar-stream@^2.1.4:
readable-stream "^3.1.1"
tar-stream@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.0.tgz#0d0064d9b67ea3c9f5abde155e35faab0df37591"
integrity sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==
version "3.2.2"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.2.tgz#aec7f257e712192a177a93aa0450c2e76855c8be"
integrity sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==
dependencies:
b4a "^1.6.4"
bare-fs "^4.5.5"
fast-fifo "^1.2.0"
streamx "^2.15.0"
b4a "^1.9.0"
bare-fs "^4.8.2"
fast-fifo "^1.3.2"
streamx "^2.28.1"
tar@^7.5.10, tar@^7.5.4:
version "7.5.22"
@@ -2408,9 +2400,9 @@ touch@^3.1.0:
integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==
tslib@^2.0.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
version "2.9.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.9.1.tgz#8986213ba2f17f38ba39a7c43d0e565a9134ff5f"
integrity sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==
tunnel-agent@^0.6.0:
version "0.6.0"
@@ -2419,7 +2411,7 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"
type-is@^2.0.1, type-is@^2.1.0:
type-is@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570"
integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==
@@ -2434,14 +2426,9 @@ undefsafe@^2.0.5:
integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==
undici@^6.25.0:
version "6.28.0"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c"
integrity sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==
undici@^8.10.2:
version "8.10.2"
resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7"
integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==
version "6.29.1"
resolved "https://registry.yarnpkg.com/undici/-/undici-6.29.1.tgz#a3317c5df0236e0633727d9a7bd4f7ed195d8575"
integrity sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==
unpipe@~1.0.0:
version "1.0.0"
+1
View File
@@ -37,6 +37,7 @@ services:
- website1.example.com
- website2.example.com
- website3.example.com
- website4.example.com
examplesite:
image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
+1
View File
@@ -16,6 +16,7 @@ services:
- website1.example.com
- website2.example.com
- website3.example.com
- website4.example.com
environment:
TZ: "${TZ:-Australia/Brisbane}"
PUID: 1000
+3
View File
@@ -17,6 +17,9 @@ server {
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
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_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
proxy_cache public-cache;
proxy_cache_key $host$request_uri;
proxy_cache_key $forward_host$request_uri;
# ignore these headers for media
proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires;
@@ -1,5 +1,5 @@
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-Proto $x_forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -17,6 +17,11 @@ server {
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
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_read_timeout 15m;
+12
View File
@@ -52,6 +52,18 @@ http {
# Dynamically generated resolvers file
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
map $host $forward_scheme {
default http;
@@ -39,3 +39,7 @@ touch /var/log/nginx/error.log || true
chmod 777 /var/log/nginx/error.log || true
chmod -R 777 /var/cache/nginx || true
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
@@ -49,7 +49,9 @@ 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
chown "$PUID:$PGID" /data/keys.json
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
@@ -16,7 +16,16 @@ if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
fi
PRODFILE="/etc/nginx/conf.d/production.conf"
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g"
# 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
@@ -24,6 +33,8 @@ 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
+1 -1
View File
@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
GREEN='\E[1;32m'
RESET='\E[0m'
S6_OVERLAY_VERSION=3.2.3.0
S6_OVERLAY_VERSION=3.2.3.2
TARGETPLATFORM=${1:-linux/amd64}
# Determine the correct binary file for the architecture given
+435 -435
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
+1
View File
@@ -23,6 +23,7 @@ const allLocales = [
["cs", "cs-CZ"],
["vi", "vi-VN"],
["zh", "zh-CN"],
["zh-tw", "zh-TW"],
["ko", "ko-KR"],
["bg", "bg-BG"],
["id", "id-ID"],
+23 -23
View File
@@ -16,11 +16,11 @@
"test": "vitest"
},
"dependencies": {
"@tabler/core": "^1.5.1",
"@tabler/icons-react": "^3.46.0",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^9.2.4",
"@uiw/react-textarea-code-editor": "^3.1.1",
"@tabler/core": "^1.7.4",
"@tabler/icons-react": "^3.51.0",
"@tanstack/react-query": "^5.107.1",
"@tanstack/react-table": "^9.3.1",
"@uiw/react-textarea-code-editor": "^3.1.3",
"classnames": "^2.5.1",
"country-flag-icons": "^1.6.20",
"date-fns": "^4.4.0",
@@ -28,40 +28,40 @@
"formik": "^2.4.9",
"generate-password-browser": "^1.1.0",
"humps": "^2.0.1",
"query-string": "^9.5.1",
"react": "^19.3.0",
"query-string": "^10.0.0",
"react": "^19.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.3.0",
"react-intl": "^10.2.0",
"react-dom": "^19.3.1",
"react-intl": "^12.1.7",
"react-markdown": "^10.1.0",
"react-qr-code": "^2.2.0",
"react-router-dom": "^7.18.4",
"react-router-dom": "^7.18.5",
"react-select": "^5.10.2",
"react-toastify": "^11.1.0",
"react-toastify": "^11.2.0",
"rooks": "^9.9.0"
},
"devDependencies": {
"@biomejs/biome": "^2.5.10",
"@formatjs/cli": "^6.16.30",
"@tanstack/react-query-devtools": "^5.103.1",
"@biomejs/biome": "^2.6.6",
"@formatjs/cli": "^6.18.1",
"@tanstack/react-query-devtools": "^5.107.1",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/country-flag-icons": "^1.2.2",
"@types/country-flag-icons": "^1.6.20",
"@types/humps": "^2.0.6",
"@types/node": "^26.6.2",
"@types/node": "^26.6.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-table": "^7.7.20",
"@vitejs/plugin-react": "^6.1.1",
"happy-dom": "^20.14.5",
"postcss": "^8.5.28",
"@vitejs/plugin-react": "^6.2.1",
"happy-dom": "^21.0.4",
"postcss": "^8.5.32",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.104.1",
"sass": "^1.108.1",
"tmp": "^0.2.7",
"typescript": "7.0.2",
"vite": "^8.3.0",
"vite-plugin-checker": "^0.14.5",
"vitest": "^4.1.11"
"vite": "^8.3.6",
"vite-plugin-checker": "^1.0.0",
"vitest": "^5.1.0"
}
}
+27
View File
@@ -115,4 +115,31 @@ label.row {
:host, :root {
margin-left: 0;
}
}
/* Keep Tabler 1.5's behaviour on Tabler 1.6/1.7. App.css loads before the
dynamically imported Tabler CSS, hence the doubled :root selectors. */
/* 1.6 dropped the reserved scrollbar gutter, so the layout shifted 7px between short and long pages */
html {
scrollbar-gutter: stable;
}
@supports not (scrollbar-gutter: stable) {
html {
overflow-y: scroll;
}
}
/* 1.6+ mixes the focus ring 50% with the background (75% dark, about 2.1:1 on white); restore full strength */
:root:root {
--tblr-focus-ring-opacity: 1;
--tblr-focus-ring-opacity-dark: 1;
}
/* Coloured primary buttons (btn-primary + bg-<colour>) got a blue primary border in 1.7 */
.btn-primary[class*=" bg-"] {
--tblr-btn-border-color: transparent;
--tblr-btn-hover-border-color: transparent;
--tblr-btn-active-border-color: transparent;
--tblr-btn-box-shadow: var(--tblr-shadow-input);
}
+5 -4
View File
@@ -46,7 +46,7 @@ function buildBody(data?: Record<string, any>): string | undefined {
}
}
async function processResponse(response: Response) {
async function processResponse(response: Response, preserveResponseKeys = false) {
const payload = await response.json();
if (!response.ok) {
if (response.status === 401) {
@@ -59,7 +59,7 @@ async function processResponse(response: Response) {
typeof payload.error.messageI18n !== "undefined" ? payload.error.messageI18n : payload.error.message,
);
}
return camelizeKeys(payload) as any;
return (preserveResponseKeys ? payload : camelizeKeys(payload)) as any;
}
interface GetArgs {
@@ -97,9 +97,10 @@ interface PostArgs {
params?: queryString.StringifiableRecord;
data?: any;
noAuth?: boolean;
preserveResponseKeys?: boolean;
}
export async function post({ url, params, data, noAuth }: PostArgs, abortController?: AbortController) {
export async function post({ url, params, data, noAuth, preserveResponseKeys }: PostArgs, abortController?: AbortController) {
const apiUrl = buildUrl({ url, params });
const method = "POST";
@@ -126,7 +127,7 @@ export async function post({ url, params, data, noAuth }: PostArgs, abortControl
const signal = abortController?.signal;
const response = await fetch(apiUrl, { method, headers, body, signal });
return processResponse(response);
return processResponse(response, preserveResponseKeys);
}
interface PutArgs {
+5
View File
@@ -16,6 +16,11 @@ export interface UserPermissions {
streams: string;
accessLists: string;
certificates: string;
meta?: {
proxyHostIds?: number[];
proxy_host_ids?: number[];
[key: string]: any;
};
}
export interface User {
@@ -0,0 +1,92 @@
import AuthStore from "src/modules/AuthStore";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "./base";
import { testHttpCertificate } from "./testHttpCertificate";
const fetchMock = vi.fn<typeof fetch>();
const reply = (payload: unknown, status = 200) => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(payload), { status }));
};
beforeEach(() => {
localStorage.clear();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
fetchMock.mockReset();
});
describe("certificate HTTP result keys", () => {
it.each([
"test-domain.example",
"test--domain.example",
"xn--bcher-kva.example",
"one.two-three.example",
"_acme-challenge.example",
"Test-Domain.example",
"plain.example",
])("preserves the exact response key %s", async (domain) => {
const result = { [domain]: "wrong-data" };
reply(result);
expect(await testHttpCertificate([domain])).toEqual(result);
});
it("keeps colliding spellings and their distinct statuses", async () => {
const result = { "test-domain.example": "ok", "test--domain.example": "no-host" };
reply(result);
expect(await testHttpCertificate(Object.keys(result))).toEqual(result);
});
it("sends unchanged domains with the normal request headers", async () => {
const domains = ["test-domain.example", "test--domain.example"];
reply({});
await testHttpCertificate(domains);
expect(fetchMock).toHaveBeenCalledWith("/api/nginx/certificates/test-http", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domains }),
signal: undefined,
});
});
it("preserves an empty result", async () => {
reply({});
expect(await testHttpCertificate([])).toEqual({});
});
it.each([
[400, { message: "invalid domain" }, "invalid domain"],
[500, { message: "fallback", messageI18n: "certificate-error" }, "certificate-error"],
])("preserves error handling for status %s", async (status, error, message) => {
reply({ error }, status);
await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow(message);
});
it("still clears authentication on 401", async () => {
const clear = vi.spyOn(AuthStore, "clear");
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {});
reply({ error: { message: "unauthorized" } }, 401);
await expect(testHttpCertificate(["test-domain.example"])).rejects.toThrow("unauthorized");
expect(clear).toHaveBeenCalledOnce();
expect(reload).toHaveBeenCalledOnce();
});
it.each([
["get", api.get],
["post", api.post],
["put", api.put],
["del", api.del],
] as const)("keeps default %s response normalization", async (_name, method) => {
reply({ domain_names: ["test-domain.example"], nested_value: { created_on: "today" } });
expect(await method({ url: "/control" })).toEqual({
domainNames: ["test-domain.example"],
nestedValue: { createdOn: "today" },
});
});
it("preserves POST abort forwarding and request normalization", async () => {
const controller = new AbortController();
reply({ domain_names: [] });
await api.post({ url: "/control", data: { domainNames: ["a-b.example"] } }, controller);
expect(fetchMock).toHaveBeenCalledWith(
"/api/control",
expect.objectContaining({
body: JSON.stringify({ domain_names: ["a-b.example"] }),
signal: controller.signal,
}),
);
});
});
@@ -3,6 +3,7 @@ import * as api from "./base";
export async function testHttpCertificate(domains: string[]): Promise<Record<string, string>> {
return await api.post({
url: "/nginx/certificates/test-http",
preserveResponseKeys: true,
data: {
domains,
},
@@ -38,8 +38,7 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
};
const setFormField = (newValues: AccessListItem[]) => {
const filtered = newValues.filter((v: AccessListItem) => v?.username?.trim() !== "");
setFieldValue(name, filtered);
setFieldValue(name, newValues);
};
return (
@@ -0,0 +1,63 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Form } from "formik";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BasicAuthFields } from "./BasicAuthFields";
import { FormModal } from "./FormModal";
import { LocationsFields } from "./LocationsFields";
vi.mock("./AccessField", () => ({ AccessField: () => null }));
const renderDraft = (initialValues: Record<string, unknown>, children: ReactNode) => {
const onHide = vi.fn();
render(
<FormModal show onHide={onHide} initialValues={initialValues} onSubmit={vi.fn()}>
{({ onHide: hide }) => (
<Form>
{children}
<button type="button" onClick={hide}>
Cancel
</button>
</Form>
)}
</FormModal>,
);
return onHide;
};
describe("FormModal local drafts", () => {
beforeEach(() => {
vi.stubGlobal(
"confirm",
vi.fn(() => false),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("protects a location draft without a path", () => {
const onHide = renderDraft({ locations: [] }, <LocationsFields initialValues={[]} />);
fireEvent.click(screen.getByRole("button", { name: "Add Location" }));
fireEvent.change(screen.getByPlaceholderText("eg: 10.0.0.1/path/"), { target: { value: "example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(window.confirm).toHaveBeenCalledOnce();
expect(onHide).not.toHaveBeenCalled();
});
it("protects a basic auth password draft without a username", () => {
const onHide = renderDraft({ items: [] }, <BasicAuthFields initialValues={[]} />);
fireEvent.change(document.querySelector('input[type="password"]') as Element, {
target: { value: "secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(window.confirm).toHaveBeenCalledOnce();
expect(onHide).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Field, Form } from "formik";
import Modal from "react-bootstrap/Modal";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FormModal } from "./FormModal";
const renderModal = (onHide = vi.fn(), preventHide = false) => {
render(
<FormModal show onHide={onHide} preventHide={preventHide} initialValues={{ name: "" }} onSubmit={vi.fn()}>
{({ onHide: hide }) => (
<Form>
<Modal.Header closeButton />
<label htmlFor="name">Name</label>
<Field id="name" name="name" />
<button type="button" onClick={hide}>
Cancel
</button>
</Form>
)}
</FormModal>,
);
return onHide;
};
describe("FormModal", () => {
beforeEach(() => {
vi.stubGlobal("confirm", vi.fn());
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("dismisses a pristine form without confirmation", () => {
const onHide = renderModal();
const confirm = vi.spyOn(window, "confirm");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(confirm).not.toHaveBeenCalled();
expect(onHide).toHaveBeenCalledOnce();
});
it("keeps a dirty form open when dismissal is cancelled", () => {
const onHide = renderModal();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByRole("button", { name: "Close" }));
fireEvent.click(document.querySelector(".modal") as Element);
fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
expect(confirm).toHaveBeenCalledTimes(4);
expect(onHide).not.toHaveBeenCalled();
expect(screen.getByLabelText("Name")).toHaveValue("example.com");
});
it("dismisses a dirty form after confirmation", () => {
const onHide = renderModal();
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onHide).toHaveBeenCalledOnce();
});
it("does not offer to discard changes while submission is in progress", () => {
const onHide = renderModal(vi.fn(), true);
const confirm = vi.spyOn(window, "confirm");
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(confirm).not.toHaveBeenCalled();
expect(onHide).not.toHaveBeenCalled();
expect(screen.getByLabelText("Name")).toHaveValue("example.com");
});
});
@@ -0,0 +1,55 @@
import { Formik, type FormikConfig, type FormikProps, type FormikValues } from "formik";
import { type ReactNode, useEffect } from "react";
import Modal from "react-bootstrap/Modal";
import { intl } from "src/locale";
const confirmDiscardChanges = (dirty: boolean) =>
!dirty ||
window.confirm(
intl.formatMessage({
id: "form.unsaved-changes",
defaultMessage: "You have unsaved changes. Are you sure you want to discard them?",
}),
);
const FormDirtyState = ({ dirty, onChange }: { dirty: boolean; onChange: (dirty: boolean) => void }) => {
useEffect(() => {
onChange(dirty);
return () => onChange(false);
}, [dirty, onChange]);
return null;
};
interface FormModalProps<Values extends FormikValues> extends Omit<FormikConfig<Values>, "children"> {
show: boolean;
onHide: () => void;
preventHide?: boolean;
children: (props: FormikProps<Values> & { onHide: () => void }) => ReactNode;
}
const FormModal = <Values extends FormikValues>({
show,
onHide,
preventHide = false,
children,
...formikProps
}: FormModalProps<Values>) => (
<Formik {...formikProps}>
{(formikProps) => {
const handleHide = () => {
if (!preventHide && confirmDiscardChanges(formikProps.dirty)) {
onHide();
}
};
return (
<Modal show={show} onHide={handleHide}>
{children({ ...formikProps, onHide: handleHide })}
</Modal>
);
}}
</Formik>
);
export { confirmDiscardChanges, FormDirtyState, FormModal };
@@ -30,8 +30,9 @@ interface Row {
interface Props {
initialValues: ProxyLocation[];
name?: string;
allowAdvanced?: boolean;
}
export function LocationsFields({ initialValues, name = "locations" }: Props) {
export function LocationsFields({ initialValues, name = "locations", allowAdvanced = true }: Props) {
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
const { setFieldValue } = useFormikContext();
const [expanded, setExpanded] = useState<number[]>([]);
@@ -88,8 +89,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
};
const setFormField = (newRows: Row[]) => {
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
setFieldValue(name, filtered);
setFieldValue(
name,
newRows.map((r: Row) => r.value),
);
};
const forwardSummary = (item: ProxyLocation) => {
@@ -215,7 +218,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
{isOpen && (
<div className="card-body" id={bodyId}>
<div className="row">
<div className="col-md-10">
<div className={allowAdvanced ? "col-md-10" : "col-md-12"}>
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
@@ -228,23 +231,25 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
aria-expanded={advVisible.includes(row.id)}
onClick={() => toggleAdvVisible(row.id)}
>
<IconSettings size={20} />
</button>
</div>
{allowAdvanced && (
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
aria-expanded={advVisible.includes(row.id)}
onClick={() => toggleAdvVisible(row.id)}
>
<IconSettings size={20} />
</button>
</div>
)}
</div>
<div className="row">
<div className="col-md-3">
<div className="col-sm-3 col-md-3">
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor={`location-forwardScheme-${row.id}`}
>
<T id="host.forward-scheme" />
@@ -262,10 +267,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
</select>
</div>
</div>
<div className="col-md-6">
<div className="col-sm-5 col-md-5">
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor={`location-forwardHost-${row.id}`}
>
<T id="proxy-host.forward-host" />
@@ -283,10 +288,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
/>
</div>
</div>
<div className="col-md-3">
<div className="col-sm-4 col-md-4">
<div className="mb-3">
<label
className="form-label"
className="form-label text-nowrap"
htmlFor={`location-forwardPort-${row.id}`}
>
<T id="host.forward-port" />
@@ -314,7 +319,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
onFormChange={(value) => handleAccessListChange(row.id, value)}
inheritHost
/>
{advVisible.includes(row.id) && (
{allowAdvanced && advVisible.includes(row.id) && (
<div className="">
<CodeEditor
language="nginx"
+1
View File
@@ -3,6 +3,7 @@ export * from "./AccessField";
export * from "./BasicAuthFields";
export * from "./DNSProviderFields";
export * from "./DomainNamesField";
export * from "./FormModal";
export * from "./LocationsFields";
export * from "./NginxConfigField";
export * from "./SSLCertificateField";
@@ -1,18 +1,95 @@
import cn from "classnames";
import { differenceInDays, isPast } from "date-fns";
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Popover from "react-bootstrap/Popover";
import type { Certificate } from "src/api/backend";
import { T } from "src/locale";
import { DateFormatter } from "src/components";
import { parseDate, T } from "src/locale";
const getProviderLabel = (provider: string) => {
if (provider === "letsencrypt") {
return "lets-encrypt";
}
if (provider === "other") {
return "certificates.custom";
}
return provider;
};
interface Props {
certificate?: Certificate;
}
export function CertificateFormatter({ certificate }: Props) {
let translation = "http-only";
if (certificate) {
translation = certificate.provider;
if (translation === "letsencrypt") {
translation = "lets-encrypt";
} else if (translation === "other") {
translation = "certificates.custom";
}
if (!certificate) {
return <T id="http-only" />;
}
return <T id={translation} />;
const translation = getProviderLabel(certificate.provider);
const expires = certificate.expiresOn ? parseDate(certificate.expiresOn) : null;
const expired = expires ? isPast(expires) : false;
const daysLeft = expires ? differenceInDays(expires, new Date()) : null;
const labelClass = cn({
"text-danger": expired,
"text-warning": !expired && daysLeft !== null && daysLeft <= 30,
});
const name = certificate.niceName || certificate.domainNames?.[0] || `#${certificate.id}`;
const popover = (
<Popover id={`popover-certificate-${certificate.id}`}>
<Popover.Body>
<div className="mb-2">
<strong>{name}</strong>
<span className="text-secondary ms-1">#{certificate.id}</span>
</div>
<div>
<strong>
<T id="column.provider" />
</strong>
</div>
<div className="ms-1 mb-2">
<T id={translation} />
</div>
{certificate.domainNames?.length ? (
<>
<div>
<strong>
<T id="domain-names" />
</strong>
</div>
{certificate.domainNames.map((domain) => (
<div key={domain} className="ms-1">
{domain}
</div>
))}
</>
) : null}
{certificate.expiresOn ? (
<>
<div className="mt-2">
<strong>
<T id="column.expires" />
</strong>
</div>
<div className="ms-1">
<DateFormatter value={certificate.expiresOn} highlightPast highlistNearlyExpired />
</div>
</>
) : null}
</Popover.Body>
</Popover>
);
return (
<OverlayTrigger trigger={["hover", "click", "focus"]} placement="bottom" overlay={popover}>
<div style={{ cursor: "help" }}>
<span className={labelClass}>
<T id={translation} />
</span>
{certificate.provider === "other" && certificate.niceName ? (
<div className="text-secondary small">{certificate.niceName}</div>
) : null}
</div>
</OverlayTrigger>
);
}
+16 -6
View File
@@ -18,6 +18,7 @@ import langSk from "./lang/sk.json";
import langCs from "./lang/cs.json";
import langVi from "./lang/vi.json";
import langZh from "./lang/zh.json";
import langZhTw from "./lang/zh-tw.json";
import langTr from "./lang/tr.json";
import langHu from "./lang/hu.json";
import langNo from "./lang/no.json";
@@ -27,7 +28,7 @@ import langFa from "./lang/fa.json";
import langList from "./lang/lang-list.json";
// first item of each array should be the language code,
// not the country code
// or the full locale when multiple variants share a language
// Remember when adding to this list, also update check-locales.js script
const localeOptions = [
["en", "en-US", langEn],
@@ -46,6 +47,7 @@ const localeOptions = [
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["zh-TW", "zh-TW", langZhTw],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
@@ -58,18 +60,26 @@ const localeOptions = [
];
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
const thisLocale = (locale || "en").slice(0, 2);
const requestedLocale = (locale || "en").toLowerCase();
const shortLocale = requestedLocale.slice(0, 2);
const localeOption =
localeOptions.find((option) => String(option[1]).toLowerCase() === requestedLocale) ??
localeOptions.find((option) => String(option[0]).toLowerCase() === shortLocale);
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
if (!localeOption || localeOption[0] === "en") {
return Object.assign({}, langList, langEn);
}
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
return Object.assign({}, langList, langEn, localeOption[2]);
};
const getFlagCodeForLocale = (locale?: string) => {
const thisLocale = (locale || "en").slice(0, 2);
const requestedLocale = (locale || "en").toLowerCase();
if (requestedLocale === "zh-tw") {
return "TW";
}
const thisLocale = requestedLocale.slice(0, 2);
// only add to this if your flag is different from the locale code
const specialCases: Record<string, string> = {
@@ -160,4 +170,4 @@ const T = ({
//console.log("L:", localeOptions);
export { localeOptions, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };
export { localeOptions, loadMessages, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };
+54 -1
View File
@@ -1,4 +1,6 @@
import { formatDateTime, getFlagCodeForLocale } from "src/locale";
import { changeLocale, formatDateTime, getFlagCodeForLocale, getLocale, loadMessages } from "src/locale";
import { getHelpFile } from "src/locale/src/HelpDoc";
import * as zhTwHelp from "src/locale/src/HelpDoc/zh-tw";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("DateFormatter", () => {
@@ -83,6 +85,9 @@ describe("getFlagCodeForLocale", () => {
it("returns correct flag code for special-case locales", () => {
expect(getFlagCodeForLocale("ja-JP")).toBe("JP");
expect(getFlagCodeForLocale("zh-CN")).toBe("CN");
expect(getFlagCodeForLocale("zh")).toBe("CN");
expect(getFlagCodeForLocale("zh-TW")).toBe("TW");
expect(getFlagCodeForLocale("ZH-tw")).toBe("TW");
expect(getFlagCodeForLocale("vi-VN")).toBe("VN");
expect(getFlagCodeForLocale("ko-KR")).toBe("KR");
expect(getFlagCodeForLocale("cs-CZ")).toBe("CZ");
@@ -112,3 +117,51 @@ describe("formatDateTime Estonian locale", () => {
}
});
});
describe("formatDateTime Vietnamese locale", () => {
it("uses 24-hour clock for vi and vi-VN", () => {
const value = "2024-06-15T15:30:00.000Z";
for (const locale of ["vi", "vi-VN"]) {
const text = formatDateTime(value, locale);
// Vietnamese 12-hour output marks the period with SA (sáng) / CH (chiều)
expect(text).not.toMatch(/\b(SA|CH)\b/);
}
});
});
describe("Chinese locale variants", () => {
it("loads Traditional Chinese by full locale before falling back to Simplified Chinese", () => {
expect(loadMessages("ZH-tw")).toEqual(loadMessages("zh-TW"));
expect(loadMessages("zh-TW").save).toBe("儲存");
expect(loadMessages("zh").save).toBe("保存");
expect(loadMessages("zh-CN")).toEqual(loadMessages("zh"));
expect(loadMessages("zh-HK")).toEqual(loadMessages("zh"));
});
it("preserves the full locale and resets RTL layout when switching to Traditional Chinese", () => {
const originalLocale = getLocale();
const savedLocale = window.localStorage.getItem("locale");
try {
changeLocale("fa-IR");
expect(document.documentElement.dir).toBe("rtl");
changeLocale("zh-TW");
expect(getLocale()).toBe("zh-TW");
expect(window.localStorage.getItem("locale")).toBe("zh-TW");
expect(document.documentElement.lang).toBe("zh-TW");
expect(document.documentElement.dir).toBe("ltr");
} finally {
changeLocale(originalLocale);
if (savedLocale === null) {
window.localStorage.removeItem("locale");
} else {
window.localStorage.setItem("locale", savedLocale);
}
}
});
it("selects HelpDocs by full locale before falling back to the language code", () => {
expect(getHelpFile("ZH-tw", "AccessLists")).toBe(getHelpFile("zh-TW", "AccessLists"));
expect(getHelpFile("zh-TW", "AccessLists")).toBe(zhTwHelp.AccessLists.default);
expect(getHelpFile("zh-CN", "AccessLists")).toBe(getHelpFile("zh", "AccessLists"));
});
});
+1
View File
@@ -26,6 +26,7 @@ const DATE_LOCALE_BY_LANG: Record<string, string> = {
const HOUR_CYCLE_BY_LANG: Record<string, "h12" | "h23"> = {
et: "h23",
vi: "h23",
};
const formatDateTime = (value: string | number, locale = "en-US"): string => {
+37 -3
View File
@@ -17,19 +17,53 @@ import * as sk from "./sk/index";
import * as cs from "./cs/index";
import * as vi from "./vi/index";
import * as zh from "./zh/index";
import * as zhTw from "./zh-tw/index";
import * as tr from "./tr/index";
import * as hu from "./hu/index";
import * as uk from "./uk/index";
import * as az from "./az/index";
import * as fa from "./fa/index";
const items: any = { en, de, pt, es, et, ja, sk, cs, zh, pl, ru, it, vi, nl, bg, ko, ga, id, fr, tr, hu, uk, az, fa };
const items: any = {
en,
de,
pt,
es,
et,
ja,
sk,
cs,
zh,
"zh-TW": zhTw,
pl,
ru,
it,
vi,
nl,
bg,
ko,
ga,
id,
fr,
tr,
hu,
uk,
az,
fa,
};
const fallbackLang = "en";
export const getHelpFile = (lang: string, section: string): string => {
if (typeof items[lang] !== "undefined" && typeof items[lang][section] !== "undefined") {
return items[lang][section].default;
const requestedLang = lang.toLowerCase();
const exactLang = Object.keys(items).find((key) => key.toLowerCase() === requestedLang);
const shortLang = Object.keys(items).find((key) => key.toLowerCase() === requestedLang.slice(0, 2));
if (exactLang && typeof items[exactLang][section] !== "undefined") {
return items[exactLang][section].default;
}
if (shortLang && typeof items[shortLang][section] !== "undefined") {
return items[shortLang][section].default;
}
// Fallback to English
if (typeof items[fallbackLang] !== "undefined" && typeof items[fallbackLang][section] !== "undefined") {
@@ -1,9 +1,9 @@
## 접근 정책이란?
접근 정책은 특정 클라이언트 IP 주소를 허용하거나 거부할 수 있으며,
프록시 호스트에 기본 HTTP 인증(Basic Auth) 을 적용할 수 있는 기능입니다.
프록시 호스트에 기본 HTTP 인증(Basic Auth)을 적용할 수 있는 기능입니다.
하나의 접근 목록에 여러 클라이언트 규칙과 사용자 이름, 비밀번호를 추가한 뒤
하나의 접근 정책에 여러 클라이언트 규칙과 사용자 이름, 비밀번호를 추가한 뒤
이를 하나 이상의 프록시 호스트에 적용할 수 있습니다.
이 기능은 인증 기능이 없는 웹 서비스에 인증을 추가하거나,
@@ -24,5 +24,5 @@ DNS 검증 방식의 인증서는 DNS 공급자 플러그인을 사용해야 합
### 사용자 지정 인증서
이 옵션을 사용하면 직접 보유한 인증 기관(CA)에서 발급한 SSL 인증서를 직접 업로드하여 사용할 수 있습니다.
이 옵션을 사용하면 사용 중인 인증 기관(CA)에서 발급받은 SSL 인증서를 직접 업로드하여 사용할 수 있습니다.
@@ -4,7 +4,7 @@
이 기능은 도메인이 검색 엔진에 이미 색인되어 있을 때,
더 깔끔한 오류 페이지를 제공하거나 해당 페이지가 더 이상 존재하지 않음을
검색 엔진에게 명확하게 알려야 할 때 유용합니다.
검색 엔진에 명확하게 알려야 할 때 유용합니다.
또한 404 호스트를 사용하면 접근 로그를 확인하고, 어떤 경로(Referrer)를 통해 들어왔는지 추적할 수 있다는 장점도 있습니다.
또한 이 호스트로 들어오는 요청을 로그로 추적하고 리퍼러(유입 경로)를 확인할 수 있다는 장점도 있습니다.
@@ -3,5 +3,5 @@
리다이렉션 호스트는 외부에서 들어오는 도메인 요청을 다른 도메인으로 자동 이동(리다이렉트)시키는 역할을 합니다.
이 유형의 호스트는 주로 웹사이트의 도메인이 변경되었지만,
검색 엔진이나 다른 사이트에 이전 도메인 링크가 남아 있을 때 사용하면 가장 효과적입니다.
검색 엔진이나 다른 사이트의 링크가 여전히 이전 도메인을 가리키고 있을 때 사용합니다.
@@ -1,6 +1,6 @@
## 호스트 스트림이란?
## 스트림이란?
호스트 스트림은 비교적 최근에 Nginx에 추가된 기능으로,
스트림은 비교적 최근에 Nginx에 추가된 기능으로,
TCP/UDP 트래픽을 네트워크 내의 다른 컴퓨터로 직접 전달하는 데 사용됩니다.
게임 서버나 FTP, SSH 서버 등을 운영할 때 유용하게 사용할 수 있습니다.
@@ -1,11 +1,10 @@
## Khái niệm Access List là gì?
## Danh sách truy cập là gì?
Access List (Danh sách truy cập) cung cấp cơ chế chặn (blacklist) hoặc cho phép (whitelist) các địa chỉ IP của client, đồng thời hỗ trợ xác thực Basic HTTP Authentication cho các Proxy Host.
Danh sách truy cập (Access List) cung cấp danh sách chặn hoặc danh sách cho phép theo địa chỉ IP của client, đồng thời hỗ trợ xác thực HTTP cơ bản (Basic HTTP Authentication) cho các máy chủ proxy.
Bạn có thể cấu hình nhiều quy tắc client, nhiều tên người dùng và mật khẩu trong một Access List duy nhất, sau đó áp dụng Access List đó cho một hoặc nhiều Proxy Host.
Bạn có thể cấu hình nhiều quy tắc client, nhiều tên người dùng và mật khẩu trong một danh sách truy cập, sau đó áp dụng nó cho một hoặc nhiều _máy chủ proxy_.
Tính năng này đặc biệt hữu ích đối với:
Tính năng này đặc biệt hữu ích khi:
các dịch vụ web được forward mà không có cơ chế xác thực tích hợp, hoặc
khi bạn muốn bảo vệ tài nguyên khỏi những client không xác định.
- dịch vụ web được chuyển tiếp không có sẵn cơ chế xác thực, hoặc
- bạn muốn chặn các client không xác định.
@@ -1,25 +1,24 @@
## Hỗ trợ Chứng chỉ
## Trợ giúp về chứng chỉ
### Chứng chỉ HTTP (HTTP Certificate)
### Chứng chỉ HTTP
Chứng chỉ được xác thực qua HTTP nghĩa là máy chủ của Let's Encrypt sẽ cố gắng truy cập vào tên miền của bạn thông qua HTTP (không phải HTTPS!). Nếu kiểm tra thành công, chứng chỉ sẽ được cấp.
Chứng chỉ xác thực qua HTTP nghĩa là máy chủ của Let's Encrypt sẽ truy cập vào tên miền của bạn qua HTTP (không phải HTTPS!). Nếu kiểm tra thành công, chứng chỉ sẽ được cấp.
Với phương thức này, bạn phải tạo trước một Proxy Host cho tên miền, có thể truy cập qua HTTP và trỏ về đúng cài đặt Nginx này.
Sau khi chứng chỉ được cấp, bạn có thể chỉnh sửa Proxy Host để sử dụng chứng chỉ đó cho kết nối HTTPS.
Với phương thức này, bạn phải tạo trước một _máy chủ proxy_ cho tên miền, truy cập được qua HTTP và trỏ về máy chủ Nginx này.
Sau khi chứng chỉ được cấp, bạn có thể chỉnh sửa _máy chủ proxy_ để dùng chứng chỉ đó cho kết nối HTTPS.
Tuy nhiên, _máy chủ proxy_ vẫn phải truy cập được qua HTTP thì chứng chỉ mới gia hạn được.
Tuy nhiên, Proxy Host vẫn phải hỗ trợ truy cập HTTP để việc gia hạn chứng chỉ diễn ra bình thường.
Phương thức này _không hỗ trợ_ tên miền wildcard (ví dụ `*.example.com`).
Phương thức này _không hỗ trợ_ wildcard domain.
### Chứng chỉ DNS
### Chứng chỉ DNS (DNS Certificate)
Chứng chỉ xác thực qua DNS yêu cầu bạn dùng plugin của nhà cung cấp DNS.
Plugin sẽ tạo các bản ghi tạm thời cho tên miền của bạn, sau đó Let's Encrypt truy vấn các bản ghi này để xác nhận bạn là chủ sở hữu. Nếu hợp lệ, chứng chỉ sẽ được cấp.
Chứng chỉ được xác thực qua DNS yêu cầu bạn sử dụng plugin của DNS Provider.
Plugin này sẽ tạo các bản ghi tạm thời trong DNS của bạn để Let's Encrypt kiểm tra quyền sở hữu tên miền. Nếu hợp lệ, chứng chỉ sẽ được cấp.
Bạn không cần tạo _máy chủ proxy_ trước khi yêu cầu loại chứng chỉ này, cũng không cần cấu hình _máy chủ proxy_ cho truy cập HTTP.
Khi dùng phương thức này: Bạn không cần tạo sẵn Proxy Host trước và bạn không cần mở HTTP cho Proxy Host.
Phương thức này _có hỗ trợ_ tên miền wildcard.
Phương thức DNS _có hỗ trợ_ wildcard domain.
### Chứng chỉ tùy chỉnh
### Chứng chỉ tùy chỉnh (Custom Certificate)
Tùy chọn này cho phép bạn tải lên chứng chỉ SSL của riêng mình, được cung cấp bởi Certificate Authority (CA) mà bạn tự chọn.
Dùng tùy chọn này để tải lên chứng chỉ SSL của riêng bạn, do tổ chức cấp chứng chỉ (CA) của bạn cung cấp.
@@ -1,8 +1,7 @@
## 404 Host là gì?
## Máy chủ 404 là gì?
404 Host đơn giản là một host được thiết lập để hiển thị trang 404.
Máy chủ 404 đơn giản là một cấu hình máy chủ chỉ hiển thị trang 404.
Điều này có thể hữu ích khi tên miền của bạn vẫn xuất hiện trên các công cụ tìm kiếm và bạn muốn hiển thị một trang lỗi đẹp hơn, hoặc muốn thông báo rõ ràng cho các trình thu thập dữ liệu tìm kiếm rằng các trang thuộc tên miền đó không còn tồn tại.
Nó hữu ích khi tên miền của bạn vẫn xuất hiện trên các công cụ tìm kiếm và bạn muốn hiển thị một trang lỗi đẹp hơn, hoặc muốn báo rõ cho các trình thu thập dữ liệu rằng các trang thuộc tên miền đó không còn tồn tại.
Một lợi ích khác của việc có 404 Host là bạn có thể theo dõi nhật ký truy cập vào nó và
xem các nguồn giới thiệu (referrers).
Một lợi ích khác là bạn có thể theo dõi nhật ký các lượt truy cập vào đó và xem trang giới thiệu (referrer).
@@ -1,7 +1,7 @@
## Proxy Host là gì?
## Máy chủ proxy là gì?
Proxy Host là điểm truy cập đầu vào cho một dịch vụ web mà bạn muốn chuyển tiếp.
Máy chủ proxy là điểm tiếp nhận yêu cầu cho một dịch vụ web mà bạn muốn chuyển tiếp.
Nó cung cấp khả năng kết thúc SSL (SSL termination) tùy chọn cho các dịch vụ vốn không hỗ trợ SSL tích hợp.
Nó có thể đảm nhận phần mã hóa SSL (SSL termination) cho những dịch vụ không có sẵn hỗ trợ SSL.
Proxy Host là loại cấu hình phổ biến nhất trong Nginx Proxy Manager.
Máy chủ proxy là tính năng được dùng nhiều nhất trong Nginx Proxy Manager.
@@ -1,5 +1,5 @@
## Redirection Host là gì?
## Máy chủ chuyển hướng là gì?
Redirection Host sẽ chuyển hướng các yêu cầu từ tên miền truy cập vào và đưa người xem sang một tên miền khác
Máy chủ chuyển hướng sẽ chuyển các yêu cầu tới tên miền này sang một tên miền khác.
Lý do phổ biến nhất để sử dụng loại host này là khi trang web của bạn đổi sang tên miền mới nhưng vẫn còn các liên kết từ công cụ tìm kiếm hoặc nguồn giới thiệu trỏ về tên miền cũ.
Lý do phổ biến nhất để dùng loại máy chủ này là khi trang web của bạn đổi sang tên miền mới nhưng các công cụ tìm kiếm hoặc trang giới thiệu vẫn còn liên kết trỏ về tên miền cũ.
@@ -1,6 +1,5 @@
## Stream là gì?
## Luồng TCP/UDP là gì?
Stream là một tính năng tương đối mới của Nginx, dùng để chuyển tiếp lưu lượng
TCP/UDP trực tiếp tới một máy khác trong mạng.
Luồng TCP/UDP (Stream) là một tính năng tương đối mới của Nginx, dùng để chuyển tiếp lưu lượng TCP/UDP trực tiếp tới một máy khác trong mạng.
Nếu bạn đang vận hành các máy chủ game, FTP hoặc SSH thì tính năng này sẽ rất hữu ích.
Tính năng này rất hữu ích nếu bạn đang chạy máy chủ game, FTP hoặc SSH.
@@ -0,0 +1,7 @@
## 什麼是存取清單?
存取清單可針對特定用戶端 IP 位址設定黑名單或白名單,並可為代理主機設定 HTTP 基本驗證。
您可以在單一存取清單中設定多項用戶端規則、使用者名稱與密碼,再將其套用至一個或多個 _代理主機_。
對於透過代理轉送、但本身未內建驗證機制的網路服務,或需要防止未知用戶端存取時,這項功能格外實用。
@@ -0,0 +1,21 @@
## 憑證說明
### HTTP 憑證
使用 HTTP 驗證方式申請憑證時,Let's Encrypt 伺服器會嘗試透過 HTTP(不是 HTTPS!)連線至您的網域;驗證成功後,便會核發憑證。
使用此方式前,您必須為網域建立一個可透過 HTTP 存取、且指向此 Nginx 安裝環境的 _代理主機_。憑證核發後,您可以修改該 _代理主機_,讓 HTTPS 連線也使用此憑證。不過,為了讓憑證能夠續期,_代理主機_ 仍須保留 HTTP 存取設定。
此流程不支援萬用字元網域。
### DNS 憑證
使用 DNS 驗證方式申請憑證時,您需要使用 DNS 服務供應商外掛程式。系統會透過該 DNS 服務供應商在您的網域中建立暫時性的 DNS 紀錄,接著 Let's Encrypt 會查詢這些紀錄,以確認您是網域所有者;驗證成功後,便會核發憑證。
申請此類憑證前,您不必先建立 _代理主機_,也不必將 _代理主機_ 設定為可透過 HTTP 存取。
此流程支援萬用字元網域。
### 自訂憑證
使用此選項,上傳由您自己的憑證授權單位提供的 SSL 憑證。
@@ -0,0 +1,7 @@
## 什麼是 404 主機?
404 主機就是用來顯示 404 頁面的主機設定。
當您的網域已被搜尋引擎收錄,而您想提供更友善的錯誤頁面,或明確告知搜尋引擎的索引程式該網域的頁面已不存在時,這項功能會很實用。
使用 404 主機的另一項好處,是可以從記錄檔追蹤對該主機的存取,並查看來源網址。
@@ -0,0 +1,7 @@
## 什麼是代理主機?
代理主機是接收連線的端點,會將連線轉送至您指定的網路服務。
它可選擇代為處理 SSL 連線,適用於未內建 SSL 支援的服務。
代理主機是 Nginx Proxy Manager 最常見的用途。
@@ -0,0 +1,5 @@
## 什麼是重新導向主機?
重新導向主機會將送往指定網域的請求重新導向,讓訪客前往另一個網域。
這類主機最常用於網站更換網域後,搜尋引擎或其他來源的連結仍指向舊網域的情況。
@@ -0,0 +1,5 @@
## 什麼是連接埠轉送?
連接埠轉送是 Nginx 相對較新的功能,可將 TCP/UDP 流量直接轉送至網路中的另一台電腦。
若您正在架設遊戲伺服器、FTP 伺服器或 SSH 伺服器,這項功能會很方便。
@@ -0,0 +1,6 @@
export * as AccessLists from "./AccessLists.md";
export * as Certificates from "./Certificates.md";
export * as DeadHosts from "./DeadHosts.md";
export * as ProxyHosts from "./ProxyHosts.md";
export * as RedirectionHosts from "./RedirectionHosts.md";
export * as Streams from "./Streams.md";
+66
View File
@@ -71,6 +71,12 @@
"access-list.help.rules-order": {
"defaultMessage": "Beachten Sie, dass die Anweisungen „Erlauben“ und „Verbieten“ in der Reihenfolge ihrer Definition angewendet werden."
},
"access-list.inherit": {
"defaultMessage": "Vom Proxy Host übernehmen"
},
"access-list.inherit.subtitle": {
"defaultMessage": "Verwendet die Zugriffsliste des Proxy Hosts"
},
"access-list.pass-auth": {
"defaultMessage": "Authentifizierung an Upstream weiterleiten"
},
@@ -101,6 +107,9 @@
"action.allow": {
"defaultMessage": "Erlauben"
},
"action.clear": {
"defaultMessage": "Leeren"
},
"action.close": {
"defaultMessage": "Schließen"
},
@@ -122,6 +131,9 @@
"action.enable": {
"defaultMessage": "Aktivieren"
},
"action.logs": {
"defaultMessage": "Logs"
},
"action.permissions": {
"defaultMessage": "Berechtigungen"
},
@@ -266,6 +278,9 @@
"column.email": {
"defaultMessage": "E-Mail"
},
"column.error": {
"defaultMessage": "Fehler"
},
"column.event": {
"defaultMessage": "Ereignis"
},
@@ -461,6 +476,12 @@
"loading": {
"defaultMessage": "Laden…"
},
"location.advanced-config": {
"defaultMessage": "Hat eine eigene Nginx-Konfiguration"
},
"location.filter": {
"defaultMessage": "Nach Pfad oder Ziel filtern"
},
"login.2fa-code": {
"defaultMessage": "Verifizierungscode"
},
@@ -479,6 +500,51 @@
"login.title": {
"defaultMessage": "Anmelden"
},
"logs": {
"defaultMessage": "Logs"
},
"logs.channel.access": {
"defaultMessage": "Zugriff"
},
"logs.channel.error": {
"defaultMessage": "Fehler"
},
"logs.download": {
"defaultMessage": "Herunterladen"
},
"logs.empty": {
"defaultMessage": "Noch keine Log-Einträge."
},
"logs.level": {
"defaultMessage": "Level"
},
"logs.level.all": {
"defaultMessage": "Alle Level"
},
"logs.lines": {
"defaultMessage": "Zeilen"
},
"logs.live": {
"defaultMessage": "Live"
},
"logs.paused": {
"defaultMessage": "Pausiert"
},
"logs.refresh": {
"defaultMessage": "Aktualisieren"
},
"logs.search-placeholder": {
"defaultMessage": "Log durchsuchen…"
},
"logs.source": {
"defaultMessage": "Quelle"
},
"logs.source.system": {
"defaultMessage": "System"
},
"logs.truncated-warning": {
"defaultMessage": "Es wird nur der neueste Teil dieser Datei angezeigt."
},
"nginx-config.label": {
"defaultMessage": "Benutzerdefinierte Nginx Konfiguration"
},
+12
View File
@@ -437,6 +437,9 @@
"footer.github-fork": {
"defaultMessage": "Fork me on Github"
},
"form.unsaved-changes": {
"defaultMessage": "You have unsaved changes. Are you sure you want to discard them?"
},
"host.flags.block-exploits": {
"defaultMessage": "Block Common Exploits"
},
@@ -641,12 +644,21 @@
"password.show": {
"defaultMessage": "Show Password"
},
"permissions.allowed-proxy-hosts": {
"defaultMessage": "Allowed Proxy Hosts"
},
"permissions.allowed-proxy-hosts-help": {
"defaultMessage": "Grants additional access to selected proxy hosts created by other users (in addition to the user's own created hosts)."
},
"permissions.hidden": {
"defaultMessage": "Hidden"
},
"permissions.manage": {
"defaultMessage": "Manage"
},
"permissions.no-proxy-hosts": {
"defaultMessage": "No proxy hosts found"
},
"permissions.view": {
"defaultMessage": "View Only"
},
+12
View File
@@ -437,6 +437,9 @@
"footer.github-fork": {
"defaultMessage": "Tee GitHubis fork"
},
"form.unsaved-changes": {
"defaultMessage": "Sul on salvestamata muudatusi. Kas oled kindel, et soovid need tühistada?"
},
"host.flags.block-exploits": {
"defaultMessage": "Blokeeri levinud ründed"
},
@@ -641,12 +644,21 @@
"password.show": {
"defaultMessage": "Näita parooli"
},
"permissions.allowed-proxy-hosts": {
"defaultMessage": "Lubatud puhverserverid"
},
"permissions.allowed-proxy-hosts-help": {
"defaultMessage": "Annab täiendava ligipääsu teiste kasutajate loodud valitud puhverserveritele (lisaks kasutaja enda loodud kirjetele)."
},
"permissions.hidden": {
"defaultMessage": "Peidetud"
},
"permissions.manage": {
"defaultMessage": "Halda"
},
"permissions.no-proxy-hosts": {
"defaultMessage": "Ühtegi puhverserverit ei leitud"
},
"permissions.view": {
"defaultMessage": "Ainult vaatamine"
},
+98 -32
View File
@@ -1,6 +1,6 @@
{
"2fa.backup-codes-remaining": {
"defaultMessage": "Codes de secours restants : {count}"
"defaultMessage": "Codes de secours restants : {count}"
},
"2fa.backup-warning": {
"defaultMessage": "Sauvegardez ces codes de secours dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois."
@@ -71,6 +71,12 @@
"access-list.help.rules-order": {
"defaultMessage": "Notez que les directives autoriser et refuser seront appliquées dans l'ordre où elles sont définies."
},
"access-list.inherit": {
"defaultMessage": "Hériter de l'hôte proxy"
},
"access-list.inherit.subtitle": {
"defaultMessage": "Utilise la liste d'accès de l'hôte proxy"
},
"access-list.pass-auth": {
"defaultMessage": "Transmettre l'authentification au serveur en amont"
},
@@ -84,10 +90,10 @@
"defaultMessage": "192.168.1.100 ou 192.168.1.0/24 ou 2001:0db8::/32"
},
"access-list.satisfy-any": {
"defaultMessage": "Valide n'importe quelle règle"
"defaultMessage": "Satisfaire au moins une condition"
},
"access-list.subtitle": {
"defaultMessage": "{utilisateurs} {utilisateurs, plural, one {Utilisateur} other {Utilisateurs}}, {règles} {règles, plural, one {Règle} other {Règles}} - Crée : {date}"
"defaultMessage": "{users} {users, plural, one {Utilisateur} other {Utilisateurs}}, {rules} {rules, plural, one {Règle} other {Règles}} - Créée le {date}"
},
"access-lists": {
"defaultMessage": "Listes d'accès"
@@ -96,11 +102,14 @@
"defaultMessage": "Ajouter"
},
"action.add-location": {
"defaultMessage": "Ajouter localisation"
"defaultMessage": "Ajouter un emplacement"
},
"action.allow": {
"defaultMessage": "Autoriser"
},
"action.clear": {
"defaultMessage": "Effacer"
},
"action.close": {
"defaultMessage": "Fermer"
},
@@ -122,6 +131,9 @@
"action.enable": {
"defaultMessage": "Activer"
},
"action.logs": {
"defaultMessage": "Journaux"
},
"action.permissions": {
"defaultMessage": "Permissions"
},
@@ -129,7 +141,7 @@
"defaultMessage": "Renouveler"
},
"action.view-details": {
"defaultMessage": "Voir les Détails"
"defaultMessage": "Voir les détails"
},
"auditlogs": {
"defaultMessage": "Journaux d'audit"
@@ -147,7 +159,7 @@
"defaultMessage": "Certificat"
},
"certificate.custom-certificate-key": {
"defaultMessage": "Clé du Certificat"
"defaultMessage": "Clé du certificat"
},
"certificate.custom-intermediate": {
"defaultMessage": "Certificat intermédiaire"
@@ -168,7 +180,7 @@
"defaultMessage": "Non utilisé"
},
"certificate.renew": {
"defaultMessage": "Renouveler Certificat"
"defaultMessage": "Renouveler le certificat"
},
"certificates": {
"defaultMessage": "Certificats"
@@ -189,7 +201,7 @@
"defaultMessage": "Ces données seront stockées en clair dans la base de données et dans un fichier !"
},
"certificates.dns.propagation-seconds": {
"defaultMessage": "Propagation Seconds"
"defaultMessage": "Délai de propagation (secondes)"
},
"certificates.dns.propagation-seconds-note": {
"defaultMessage": "Laisser vide pour utiliser la valeur par défaut du plugin. Nombre de secondes à attendre pour la propagation DNS."
@@ -216,10 +228,10 @@
"defaultMessage": "Votre serveur est accessible et la création de certificats devrait être possible."
},
"certificates.http.reachability-other": {
"defaultMessage": "Un serveur a été trouvé sur ce domaine, mais il a renvoyé un code d'état inattendu {code}. S'agit-il du serveur NPM ? Veuillez vérifier que votre domaine pointe bien vers l'adresse IP où votre instance NPM est exécutée."
"defaultMessage": "Un serveur a été trouvé sur ce domaine, mais il a renvoyé un code d'état inattendu {code}. S'agit-il du serveur NPM ? Veuillez vérifier que votre domaine pointe bien vers l'adresse IP où votre instance NPM est exécutée."
},
"certificates.http.reachability-wrong-data": {
"defaultMessage": "Un serveur a été trouvé sur ce domaine, mais il a renvoyé des données inattendues. S'agit-il du serveur NPM ? Veuillez vérifier que votre domaine pointe bien vers l'adresse IP où votre instance NPM est exécutée."
"defaultMessage": "Un serveur a été trouvé sur ce domaine, mais il a renvoyé des données inattendues. S'agit-il du serveur NPM ? Veuillez vérifier que votre domaine pointe bien vers l'adresse IP où votre instance NPM est exécutée."
},
"certificates.http.test-results": {
"defaultMessage": "Résultats du test"
@@ -255,7 +267,7 @@
"defaultMessage": "Autorisations"
},
"column.custom-locations": {
"defaultMessage": "Emplacement personnalisé"
"defaultMessage": "Emplacements personnalisés"
},
"column.destination": {
"defaultMessage": "Destination"
@@ -266,6 +278,9 @@
"column.email": {
"defaultMessage": "e-mail"
},
"column.error": {
"defaultMessage": "Erreurs"
},
"column.event": {
"defaultMessage": "Évènement"
},
@@ -294,13 +309,13 @@
"defaultMessage": "Règles"
},
"column.satisfy": {
"defaultMessage": "Valide"
"defaultMessage": "Conditions requises"
},
"column.satisfy-all": {
"defaultMessage": "All"
"defaultMessage": "Toutes"
},
"column.satisfy-any": {
"defaultMessage": "Any"
"defaultMessage": "Au moins une"
},
"column.scheme": {
"defaultMessage": "Schéma"
@@ -315,7 +330,7 @@
"defaultMessage": "Statut"
},
"created-on": {
"defaultMessage": "Créé : {date}"
"defaultMessage": "Création : {date}"
},
"dashboard": {
"defaultMessage": "Tableau de bord"
@@ -342,10 +357,10 @@
"defaultMessage": "Commencez à écrire pour ajouter un domaine…"
},
"domain-names.wildcards-not-permitted": {
"defaultMessage": "Les Wildcards ne sont pas permises dans ce cas"
"defaultMessage": "Les wildcards ne sont pas permises dans ce cas"
},
"domain-names.wildcards-not-supported": {
"defaultMessage": "Les Wildcards ne sont pas prises en charge par cette autorité de certification."
"defaultMessage": "Les wildcards ne sont pas prises en charge par cette autorité de certification."
},
"domains.advanced": {
"defaultMessage": "Avancé"
@@ -375,7 +390,7 @@
"defaultMessage": "Aucun résultat trouvé"
},
"empty-subtitle": {
"defaultMessage": "Pourquoi n'en créez-vous pas un ?"
"defaultMessage": "Pourquoi n'en créez-vous pas un ?"
},
"enabled": {
"defaultMessage": "Activé"
@@ -390,7 +405,7 @@
"defaultMessage": "Adresse e-mail ou mot de passe invalide"
},
"error.invalid-domain": {
"defaultMessage": "Domaine invalide : {domain}"
"defaultMessage": "Domaine invalide : {domain}"
},
"error.invalid-email": {
"defaultMessage": "Adresse e-mail invalide"
@@ -417,16 +432,16 @@
"defaultMessage": "Ceci est obligatoire"
},
"expires.on": {
"defaultMessage": "Expire : {date}"
"defaultMessage": "Expire le {date}"
},
"footer.github-fork": {
"defaultMessage": "Forkez-moi sur Github"
"defaultMessage": "Forkez-moi sur GitHub"
},
"host.flags.block-exploits": {
"defaultMessage": "Bloquer les exploits courants"
},
"host.flags.cache-assets": {
"defaultMessage": "Ressources du cache"
"defaultMessage": "Mise en cache des ressources"
},
"host.flags.preserve-path": {
"defaultMessage": "Préserver le chemin"
@@ -435,10 +450,10 @@
"defaultMessage": "Protocoles"
},
"host.flags.websockets-upgrade": {
"defaultMessage": "Prise en charge de Websockets"
"defaultMessage": "Prise en charge de WebSockets"
},
"host.forward-port": {
"defaultMessage": "Port de redirection"
"defaultMessage": "Port de destination"
},
"host.forward-scheme": {
"defaultMessage": "Schéma"
@@ -461,6 +476,12 @@
"loading": {
"defaultMessage": "Chargement…"
},
"location.advanced-config": {
"defaultMessage": "Configuration Nginx personnalisée"
},
"location.filter": {
"defaultMessage": "Filtrer par chemin ou destination"
},
"login.2fa-code": {
"defaultMessage": "Code de vérification"
},
@@ -479,11 +500,56 @@
"login.title": {
"defaultMessage": "Connectez-vous à votre compte"
},
"logs": {
"defaultMessage": "Journaux"
},
"logs.channel.access": {
"defaultMessage": "Accès"
},
"logs.channel.error": {
"defaultMessage": "Erreurs"
},
"logs.download": {
"defaultMessage": "Télécharger"
},
"logs.empty": {
"defaultMessage": "Aucune entrée pour le moment."
},
"logs.level": {
"defaultMessage": "Niveau"
},
"logs.level.all": {
"defaultMessage": "Tous les niveaux"
},
"logs.lines": {
"defaultMessage": "Lignes"
},
"logs.live": {
"defaultMessage": "En direct"
},
"logs.paused": {
"defaultMessage": "En pause"
},
"logs.refresh": {
"defaultMessage": "Actualiser"
},
"logs.search-placeholder": {
"defaultMessage": "Rechercher dans les journaux…"
},
"logs.source": {
"defaultMessage": "Source"
},
"logs.source.system": {
"defaultMessage": "Système"
},
"logs.truncated-warning": {
"defaultMessage": "Seule la partie la plus récente de ce fichier est affichée."
},
"nginx-config.label": {
"defaultMessage": "Configuration Nginx personnalisée"
},
"nginx-config.placeholder": {
"defaultMessage": "# Mettez ici votre configuration Nginx personnalisé à vos risques et périls !"
"defaultMessage": "# Mettez ici votre configuration Nginx personnalisée à vos risques et périls !"
},
"no-permission-error": {
"defaultMessage": "Vous n'avez pas la permission de voir ce contenu."
@@ -597,7 +663,7 @@
"defaultMessage": "Hôte proxy"
},
"proxy-host.forward-host": {
"defaultMessage": "Nom d'hôte de redirection / IP"
"defaultMessage": "Nom d'hôte / IP de destination"
},
"proxy-hosts": {
"defaultMessage": "Hôtes proxy"
@@ -606,7 +672,7 @@
"defaultMessage": "{count} {count, plural, one {Hôte proxy} other {Hôtes proxy}}"
},
"public": {
"defaultMessage": "Publique"
"defaultMessage": "Public"
},
"redirection-host": {
"defaultMessage": "Hôte de redirection"
@@ -669,7 +735,7 @@
"defaultMessage": "Page de félicitations"
},
"settings.default-site.description": {
"defaultMessage": "ce qu'il faut afficher lorsqu'un hôte inconnu est détecté par Nginx"
"defaultMessage": "Ce qu'il faut afficher lorsqu'un hôte inconnu est détecté par Nginx"
},
"settings.default-site.html": {
"defaultMessage": "HTML personnalisé"
@@ -696,7 +762,7 @@
"defaultMessage": "Stream"
},
"stream.forward-host": {
"defaultMessage": "Hôte destinataire"
"defaultMessage": "Hôte de destination"
},
"stream.forward-host.placeholder": {
"defaultMessage": "example.com ou 10.0.0.1 ou 2001:db8:3333:4444:5555:6666:7777:8888"
@@ -720,7 +786,7 @@
"defaultMessage": "Test"
},
"update-available": {
"defaultMessage": "Mise à jour disponible : {latestVersion}"
"defaultMessage": "Mise à jour disponible : {latestVersion}"
},
"user": {
"defaultMessage": "Utilisateur"
@@ -759,10 +825,10 @@
"defaultMessage": "Définir les autorisations pour {name}"
},
"user.switch-dark": {
"defaultMessage": "Passer au mode Sombre"
"defaultMessage": "Passer au mode sombre"
},
"user.switch-light": {
"defaultMessage": "Passer au mode Lumineux"
"defaultMessage": "Passer au mode clair"
},
"user.two-factor": {
"defaultMessage": "Auth. à deux facteurs"
+183
View File
@@ -1,4 +1,61 @@
{
"2fa.backup-codes-remaining": {
"defaultMessage": "Codici di backup rimanenti: {count}"
},
"2fa.backup-warning": {
"defaultMessage": "Conserva questi codici di backup in un luogo sicuro. Ogni codice può essere usato una sola volta."
},
"2fa.disable": {
"defaultMessage": "Disattiva Autenticazione a Due Fattori"
},
"2fa.disable-confirm": {
"defaultMessage": "Disattiva 2FA"
},
"2fa.disable-warning": {
"defaultMessage": "Disattivare l'autenticazione a due fattori renderà il tuo account meno sicuro."
},
"2fa.disabled": {
"defaultMessage": "Disattivata"
},
"2fa.done": {
"defaultMessage": "Ho salvato i miei codici di backup"
},
"2fa.enable": {
"defaultMessage": "Attiva Autenticazione a Due Fattori"
},
"2fa.enabled": {
"defaultMessage": "Attivata"
},
"2fa.enter-code": {
"defaultMessage": "Inserisci il codice di verifica"
},
"2fa.enter-code-disable": {
"defaultMessage": "Inserisci il codice di verifica per disattivarla"
},
"2fa.regenerate": {
"defaultMessage": "Rigenera"
},
"2fa.regenerate-backup": {
"defaultMessage": "Rigenera Codici di Backup"
},
"2fa.regenerate-instructions": {
"defaultMessage": "Inserisci un codice di verifica per generare nuovi codici di backup. I vecchi codici non saranno più validi."
},
"2fa.secret-key": {
"defaultMessage": "Chiave Segreta"
},
"2fa.setup-instructions": {
"defaultMessage": "Scansiona questo codice QR con la tua app di autenticazione, oppure inserisci la chiave segreta manualmente."
},
"2fa.status": {
"defaultMessage": "Stato"
},
"2fa.title": {
"defaultMessage": "Autenticazione a Due Fattori"
},
"2fa.verify-enable": {
"defaultMessage": "Verifica e Attiva"
},
"access-list": {
"defaultMessage": "Lista di Accesso"
},
@@ -14,6 +71,12 @@
"access-list.help.rules-order": {
"defaultMessage": "Nota che le direttive di allow e deny saranno applicate nell'ordine in cui sono definite."
},
"access-list.inherit": {
"defaultMessage": "Eredita dal Proxy Host"
},
"access-list.inherit.subtitle": {
"defaultMessage": "Usa la lista di accesso del proxy host"
},
"access-list.pass-auth": {
"defaultMessage": "Passa Autenticazione all'Upstream"
},
@@ -23,6 +86,9 @@
"access-list.public.subtitle": {
"defaultMessage": "Nessuna autenticazione base richiesta"
},
"access-list.rule-source.placeholder": {
"defaultMessage": "192.168.1.100 o 192.168.1.0/24 o 2001:0db8::/32"
},
"access-list.satisfy-any": {
"defaultMessage": "Soddisfa Qualsiasi"
},
@@ -38,12 +104,21 @@
"action.add-location": {
"defaultMessage": "Aggiungi Percorso"
},
"action.allow": {
"defaultMessage": "Consenti"
},
"action.clear": {
"defaultMessage": "Svuota"
},
"action.close": {
"defaultMessage": "Chiudi"
},
"action.delete": {
"defaultMessage": "Elimina"
},
"action.deny": {
"defaultMessage": "Nega"
},
"action.disable": {
"defaultMessage": "Disabilita"
},
@@ -56,6 +131,9 @@
"action.enable": {
"defaultMessage": "Abilita"
},
"action.logs": {
"defaultMessage": "Log"
},
"action.permissions": {
"defaultMessage": "Permessi"
},
@@ -68,6 +146,9 @@
"auditlogs": {
"defaultMessage": "Log di Audit"
},
"auto": {
"defaultMessage": "Auto"
},
"cancel": {
"defaultMessage": "Annulla"
},
@@ -128,6 +209,9 @@
"certificates.dns.provider": {
"defaultMessage": "Provider DNS"
},
"certificates.dns.provider.placeholder": {
"defaultMessage": "Seleziona un Provider..."
},
"certificates.dns.warning": {
"defaultMessage": "Questa sezione richiede conoscenze su Certbot e i relativi plugin DNS. Consulta la documentazione del plugin."
},
@@ -194,6 +278,9 @@
"column.email": {
"defaultMessage": "Email"
},
"column.error": {
"defaultMessage": "Errore"
},
"column.event": {
"defaultMessage": "Evento"
},
@@ -275,6 +362,9 @@
"domain-names.wildcards-not-supported": {
"defaultMessage": "Wildcard non supportate per questa CA"
},
"domains.advanced": {
"defaultMessage": "Avanzate"
},
"domains.force-ssl": {
"defaultMessage": "Forza SSL"
},
@@ -287,6 +377,9 @@
"domains.http2-support": {
"defaultMessage": "Supporto HTTP/2"
},
"domains.trust-forwarded-proto": {
"defaultMessage": "Considera Attendibili gli Header Forwarded Proto dell'Upstream"
},
"domains.use-dns": {
"defaultMessage": "Usa Challenge DNS"
},
@@ -383,9 +476,75 @@
"loading": {
"defaultMessage": "Caricamento…"
},
"location.advanced-config": {
"defaultMessage": "Ha una configurazione Nginx personalizzata"
},
"location.filter": {
"defaultMessage": "Filtra per percorso o destinazione"
},
"login.2fa-code": {
"defaultMessage": "Codice di Verifica"
},
"login.2fa-code-placeholder": {
"defaultMessage": "Inserisci il codice"
},
"login.2fa-description": {
"defaultMessage": "Inserisci il codice della tua app di autenticazione"
},
"login.2fa-title": {
"defaultMessage": "Autenticazione a Due Fattori"
},
"login.2fa-verify": {
"defaultMessage": "Verifica"
},
"login.title": {
"defaultMessage": "Accedi al tuo account"
},
"logs": {
"defaultMessage": "Log"
},
"logs.channel.access": {
"defaultMessage": "Accessi"
},
"logs.channel.error": {
"defaultMessage": "Errori"
},
"logs.download": {
"defaultMessage": "Scarica"
},
"logs.empty": {
"defaultMessage": "Nessuna voce di log per ora."
},
"logs.level": {
"defaultMessage": "Livello"
},
"logs.level.all": {
"defaultMessage": "Tutti i Livelli"
},
"logs.lines": {
"defaultMessage": "Righe"
},
"logs.live": {
"defaultMessage": "Live"
},
"logs.paused": {
"defaultMessage": "In Pausa"
},
"logs.refresh": {
"defaultMessage": "Aggiorna"
},
"logs.search-placeholder": {
"defaultMessage": "Cerca nel log…"
},
"logs.source": {
"defaultMessage": "Origine"
},
"logs.source.system": {
"defaultMessage": "Sistema"
},
"logs.truncated-warning": {
"defaultMessage": "Viene mostrata solo la parte più recente di questo file."
},
"nginx-config.label": {
"defaultMessage": "Configurazione Nginx Personalizzata"
},
@@ -530,6 +689,24 @@
"redirection-hosts.count": {
"defaultMessage": "{count} {count, plural, one {Host di Reindirizzamento} other {Host di Reindirizzamento}}"
},
"redirection-hosts.http-code.300": {
"defaultMessage": "300 Scelte multiple"
},
"redirection-hosts.http-code.301": {
"defaultMessage": "301 Spostato permanentemente"
},
"redirection-hosts.http-code.302": {
"defaultMessage": "302 Spostato temporaneamente"
},
"redirection-hosts.http-code.303": {
"defaultMessage": "303 Vedi altro"
},
"redirection-hosts.http-code.307": {
"defaultMessage": "307 Reindirizzamento temporaneo"
},
"redirection-hosts.http-code.308": {
"defaultMessage": "308 Reindirizzamento permanente"
},
"role.admin": {
"defaultMessage": "Amministratore"
},
@@ -587,6 +764,9 @@
"stream.forward-host": {
"defaultMessage": "Host di Destinazione"
},
"stream.forward-host.placeholder": {
"defaultMessage": "example.com o 10.0.0.1 o 2001:db8:3333:4444:5555:6666:7777:8888"
},
"stream.incoming-port": {
"defaultMessage": "Porta in Ingresso"
},
@@ -650,6 +830,9 @@
"user.switch-light": {
"defaultMessage": "Passa alla modalità Chiara"
},
"user.two-factor": {
"defaultMessage": "Autenticazione 2FA"
},
"username": {
"defaultMessage": "Nome Utente"
},
+207 -60
View File
@@ -1,12 +1,69 @@
{
"2fa.backup-codes-remaining": {
"defaultMessage": "남은 백업 코드: {count}개"
},
"2fa.backup-warning": {
"defaultMessage": "백업 코드를 안전한 곳에 보관하세요. 각 코드는 한 번만 사용할 수 있습니다."
},
"2fa.disable": {
"defaultMessage": "2단계 인증 비활성화"
},
"2fa.disable-confirm": {
"defaultMessage": "2FA 비활성화"
},
"2fa.disable-warning": {
"defaultMessage": "2단계 인증을 비활성화하면 계정 보안이 약해집니다."
},
"2fa.disabled": {
"defaultMessage": "비활성화"
},
"2fa.done": {
"defaultMessage": "백업 코드를 저장했습니다"
},
"2fa.enable": {
"defaultMessage": "2단계 인증 활성화"
},
"2fa.enabled": {
"defaultMessage": "활성화"
},
"2fa.enter-code": {
"defaultMessage": "인증 코드 입력"
},
"2fa.enter-code-disable": {
"defaultMessage": "비활성화하려면 인증 코드를 입력하세요"
},
"2fa.regenerate": {
"defaultMessage": "재발급"
},
"2fa.regenerate-backup": {
"defaultMessage": "백업 코드 재발급"
},
"2fa.regenerate-instructions": {
"defaultMessage": "새 백업 코드를 발급하려면 인증 코드를 입력하세요. 기존 코드는 더 이상 사용할 수 없게 됩니다."
},
"2fa.secret-key": {
"defaultMessage": "비밀 키"
},
"2fa.setup-instructions": {
"defaultMessage": "인증 앱으로 이 QR 코드를 스캔하거나 비밀 키를 직접 입력하세요."
},
"2fa.status": {
"defaultMessage": "상태"
},
"2fa.title": {
"defaultMessage": "2단계 인증"
},
"2fa.verify-enable": {
"defaultMessage": "확인 후 활성화"
},
"access-list": {
"defaultMessage": "접근 정책"
},
"access-list.access-count": {
"defaultMessage": "{count}개의 정책"
"defaultMessage": "규칙 {count}개"
},
"access-list.auth-count": {
"defaultMessage": "{count}명의 사용자"
"defaultMessage": "사용자 {count}명"
},
"access-list.help-rules-last": {
"defaultMessage": "규칙이 하나라도 있으면 아래 ‘전체 거부’ 규칙이 마지막에 추가됩니다."
@@ -14,6 +71,12 @@
"access-list.help.rules-order": {
"defaultMessage": "허용/거부 규칙은 정의된 순서대로 적용됩니다."
},
"access-list.inherit": {
"defaultMessage": "프록시 호스트 설정 상속"
},
"access-list.inherit.subtitle": {
"defaultMessage": "프록시 호스트의 접근 정책을 사용합니다"
},
"access-list.pass-auth": {
"defaultMessage": "인증 정보를 원본 서버로 전달"
},
@@ -24,13 +87,13 @@
"defaultMessage": "기본 인증 필요 없음"
},
"access-list.rule-source.placeholder": {
"defaultMessage": "192.168.1.100 / 192.168.1.0/24 / IPv6"
"defaultMessage": "192.168.1.100 또는 192.168.1.0/24 또는 2001:0db8::/32"
},
"access-list.satisfy-any": {
"defaultMessage": "조건 중 하나라도 충족"
},
"access-list.subtitle": {
"defaultMessage": "{users}명 {users, plural, one {사용자} other {사용자}}, {rules}개 {rules, plural, one {규칙} other {규칙}} - 생성일: {date}"
"defaultMessage": "사용자 {users}명, 규칙 {rules}개 - 생성일: {date}"
},
"access-lists": {
"defaultMessage": "접근 정책"
@@ -44,6 +107,9 @@
"action.allow": {
"defaultMessage": "허용"
},
"action.clear": {
"defaultMessage": "지우기"
},
"action.close": {
"defaultMessage": "닫기"
},
@@ -65,6 +131,9 @@
"action.enable": {
"defaultMessage": "활성화"
},
"action.logs": {
"defaultMessage": "로그"
},
"action.permissions": {
"defaultMessage": "권한"
},
@@ -102,13 +171,13 @@
"defaultMessage": "지정된 인증서 없음"
},
"certificate.none.subtitle.for-http": {
"defaultMessage": "이 호스트는 HTTPS를 사용하지 않습니다."
"defaultMessage": "이 호스트는 HTTPS를 사용하지 않습니다"
},
"certificate.none.title": {
"defaultMessage": "없음"
},
"certificate.not-in-use": {
"defaultMessage": "사용 안 함"
"defaultMessage": "미사용"
},
"certificate.renew": {
"defaultMessage": "인증서 갱신"
@@ -123,19 +192,19 @@
"defaultMessage": "비밀번호로 보호된 키 파일은 지원되지 않습니다."
},
"certificates.dns.credentials": {
"defaultMessage": "DNS 자격 증명 입력"
"defaultMessage": "자격 증명 파일 내용"
},
"certificates.dns.credentials-note": {
"defaultMessage": "이 플러그인은 API 토큰 등이 포함된 설정 파일이 필요합니다."
"defaultMessage": "이 플러그인을 사용하려면 DNS 공급자의 API 토큰 또는 기타 자격 증명이 포함된 설정 파일이 필요합니다."
},
"certificates.dns.credentials-warning": {
"defaultMessage": "입력한 정보는 데이터베이스와 파일에 평문으로 저장됩니다."
},
"certificates.dns.propagation-seconds": {
"defaultMessage": "DNS 전파 시간"
"defaultMessage": "DNS 전파 대기 시간(초)"
},
"certificates.dns.propagation-seconds-note": {
"defaultMessage": "비워두면 기본값을 사용합니다. DNS 전파를 기다리는 시간(초)입니다."
"defaultMessage": "비워 두면 플러그인 기본값을 사용합니다. DNS 전파를 기다릴 시간(초)입니다."
},
"certificates.dns.provider": {
"defaultMessage": "DNS 공급자"
@@ -144,37 +213,37 @@
"defaultMessage": "공급자를 선택하세요..."
},
"certificates.dns.warning": {
"defaultMessage": "이 기능을 사용하려면 Certbot과 DNS 플러그인에 대한 기본적인 이해가 필요합니다. 자세한 내용은 관련 문서를 참고해 주세요."
"defaultMessage": "이 항목을 사용하려면 Certbot과 DNS 플러그인에 대한 지식이 어느 정도 필요합니다. 각 플러그인의 문서를 참고하세요."
},
"certificates.http.reachability-404": {
"defaultMessage": "해당 도메인에서 서버가 탐지되었지만 Nginx Proxy Manager가 아닌 것으로 보입니다. 도메인이 NPM이 실행 중인 IP를 가리키는지 확인하세요."
"defaultMessage": "해당 도메인에서 서버가 발견되었지만 Nginx Proxy Manager가 아닌 것으로 보입니다. 도메인이 NPM이 실행 중인 IP를 가리키는지 확인하세요."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "site24x7.com과의 통신 오류로 인해 도달 가능 여부를 확인할 수 없습니다."
"defaultMessage": "site24x7.com과 통신하는 중 오류가 발생해 접근 가능 여부를 확인하지 못했습니다."
},
"certificates.http.reachability-not-resolved": {
"defaultMessage": "해당 도메인에 접근 가능한 서버가 없습니다. 도메인이 존재하며 NPM이 실행되는 IP를 가리키고, 필요하면 라우터에서 80포트가 포워딩되어 있는지 확인하세요."
"defaultMessage": "해당 도메인에서 사용 가능한 서버를 찾을 수 없습니다. 도메인이 존재하고 NPM이 실행 중인 IP를 가리키는지, 필요한 경우 라우터에서 80번 포트가 포워딩되어 있는지 확인하세요."
},
"certificates.http.reachability-ok": {
"defaultMessage": "서버에 정상적으로 접근할 수 있으며 인증서 발급이 가능합니다."
"defaultMessage": "서버에 정상적으로 접근할 수 있어 인증서를 발급할 수 있을 것으로 보입니다."
},
"certificates.http.reachability-other": {
"defaultMessage": "해당 도메인에서 서버가 발견되었지만 예상치 못한 상태 코드 {code}를 반환했습니다. NPM 서버가 맞는지 확인하세요."
"defaultMessage": "해당 도메인에서 서버가 발견되었지만 예상하지 못한 상태 코드({code})를 반환했습니다. NPM 서버가 맞나요? 도메인이 NPM이 실행 중인 IP를 가리키는지 확인하세요."
},
"certificates.http.reachability-wrong-data": {
"defaultMessage": "서버가 응답했지만 예상치 못한 데이터를 반환했습니다. NPM 서버가 맞는지 확인하세요."
"defaultMessage": "해당 도메인에서 서버가 발견되었지만 예상하지 못한 데이터를 반환했습니다. NPM 서버가 맞나요? 도메인이 NPM이 실행 중인 IP를 가리키는지 확인하세요."
},
"certificates.http.test-results": {
"defaultMessage": "테스트 결과"
},
"certificates.http.warning": {
"defaultMessage": "도메인이 이 서버를 가리키도록 설정되어 있어야 합니다."
"defaultMessage": "입력한 도메인은 미리 이 서버를 가리키도록 설정되어 있어야 합니다."
},
"certificates.key-type": {
"defaultMessage": "키 유형"
},
"certificates.key-type-description": {
"defaultMessage": "RSA는 호환성이 넓고, ECDSA는 더 빠르고 안전하지만 오래된 시스템에서 지원되지 않을 수 있습니다"
"defaultMessage": "RSA는 호환성이 좋고, ECDSA는 더 빠르고 안전하지만 오래된 시스템에서는 지원되지 않을 수 있습니다"
},
"certificates.key-type-ecdsa": {
"defaultMessage": "ECDSA 256"
@@ -183,16 +252,16 @@
"defaultMessage": "RSA 2048"
},
"certificates.request.subtitle": {
"defaultMessage": "Let's Encrypt 사용"
"defaultMessage": "Let's Encrypt로 발급"
},
"certificates.request.title": {
"defaultMessage": "새 인증서 요청"
},
"column.access": {
"defaultMessage": "접근 정책"
"defaultMessage": "접근"
},
"column.authorization": {
"defaultMessage": "인증 사용자"
"defaultMessage": "인증"
},
"column.authorizations": {
"defaultMessage": "인증 사용자"
@@ -209,6 +278,9 @@
"column.email": {
"defaultMessage": "이메일"
},
"column.error": {
"defaultMessage": "오류"
},
"column.event": {
"defaultMessage": "이벤트"
},
@@ -231,13 +303,13 @@
"defaultMessage": "공급자"
},
"column.roles": {
"defaultMessage": "권한"
"defaultMessage": "역할"
},
"column.rules": {
"defaultMessage": "IP 정책"
"defaultMessage": "접근 규칙"
},
"column.satisfy": {
"defaultMessage": "조건 방식"
"defaultMessage": "충족 조건"
},
"column.satisfy-all": {
"defaultMessage": "모두 충족"
@@ -270,7 +342,7 @@
"defaultMessage": "404 호스트"
},
"dead-hosts.count": {
"defaultMessage": "{count}개의 404 호스트"
"defaultMessage": "404 호스트 {count}개"
},
"disabled": {
"defaultMessage": "비활성화"
@@ -282,14 +354,17 @@
"defaultMessage": "최대 {count}개의 도메인 이름"
},
"domain-names.placeholder": {
"defaultMessage": "도메인을 입력해주세요."
"defaultMessage": "추가할 도메인을 입력하세요..."
},
"domain-names.wildcards-not-permitted": {
"defaultMessage": "HTTP 방식으로는 와일드카드 인증서를 발급할 수 없습니다."
"defaultMessage": "이 방식에서는 와일드카드 도메인을 사용할 수 없습니다."
},
"domain-names.wildcards-not-supported": {
"defaultMessage": "이 인증 기관(CA)은 와일드카드를 지원하지 않습니다."
},
"domains.advanced": {
"defaultMessage": "고급"
},
"domains.force-ssl": {
"defaultMessage": "SSL 강제 적용"
},
@@ -302,6 +377,9 @@
"domains.http2-support": {
"defaultMessage": "HTTP/2 지원"
},
"domains.trust-forwarded-proto": {
"defaultMessage": "앞단 프록시의 X-Forwarded-Proto 헤더 신뢰"
},
"domains.use-dns": {
"defaultMessage": "DNS 챌린지 사용"
},
@@ -318,7 +396,7 @@
"defaultMessage": "활성화"
},
"error.access.at-least-one": {
"defaultMessage": "인증 또는 접근 규칙 중 하나는 반드시 필요합니다."
"defaultMessage": "인증 사용자 또는 접근 규칙이 하나 이상 필요합니다."
},
"error.access.duplicate-usernames": {
"defaultMessage": "인증 사용자 이름은 중복될 수 없습니다."
@@ -348,7 +426,7 @@
"defaultMessage": "최솟값은 {min}입니다."
},
"error.passwords-must-match": {
"defaultMessage": "비밀번호가 일치해야 합니다."
"defaultMessage": "비밀번호가 일치하지 않습니다."
},
"error.required": {
"defaultMessage": "필수 항목입니다."
@@ -360,7 +438,7 @@
"defaultMessage": "GitHub에서 포크하기"
},
"host.flags.block-exploits": {
"defaultMessage": "일반적인 공격 차단"
"defaultMessage": "일반적인 취약점 공격 차단"
},
"host.flags.cache-assets": {
"defaultMessage": "정적 에셋 캐싱"
@@ -381,7 +459,7 @@
"defaultMessage": "프로토콜"
},
"hosts": {
"defaultMessage": "호스트 목록"
"defaultMessage": "호스트"
},
"http-only": {
"defaultMessage": "HTTP 전용"
@@ -398,8 +476,74 @@
"loading": {
"defaultMessage": "불러오는 중…"
},
"location.advanced-config": {
"defaultMessage": "사용자 지정 Nginx 설정 있음"
},
"location.filter": {
"defaultMessage": "경로 또는 전달 대상으로 필터링"
},
"login.2fa-code": {
"defaultMessage": "인증 코드"
},
"login.2fa-code-placeholder": {
"defaultMessage": "코드 입력"
},
"login.2fa-description": {
"defaultMessage": "인증 앱에 표시된 코드를 입력하세요"
},
"login.2fa-title": {
"defaultMessage": "2단계 인증"
},
"login.2fa-verify": {
"defaultMessage": "확인"
},
"login.title": {
"defaultMessage": "로그인"
"defaultMessage": "계정에 로그인"
},
"logs": {
"defaultMessage": "로그"
},
"logs.channel.access": {
"defaultMessage": "접근"
},
"logs.channel.error": {
"defaultMessage": "오류"
},
"logs.download": {
"defaultMessage": "다운로드"
},
"logs.empty": {
"defaultMessage": "아직 로그가 없습니다."
},
"logs.level": {
"defaultMessage": "레벨"
},
"logs.level.all": {
"defaultMessage": "모든 레벨"
},
"logs.lines": {
"defaultMessage": "줄 수"
},
"logs.live": {
"defaultMessage": "실시간"
},
"logs.paused": {
"defaultMessage": "일시 정지"
},
"logs.refresh": {
"defaultMessage": "새로고침"
},
"logs.search-placeholder": {
"defaultMessage": "로그 검색…"
},
"logs.source": {
"defaultMessage": "소스"
},
"logs.source.system": {
"defaultMessage": "시스템"
},
"logs.truncated-warning": {
"defaultMessage": "이 파일의 최근 내용 일부만 표시됩니다."
},
"nginx-config.label": {
"defaultMessage": "사용자 지정 Nginx 설정"
@@ -459,28 +603,28 @@
"defaultMessage": "{objects}이(가) 없습니다."
},
"object.event.created": {
"defaultMessage": "{object}이(가) 생성됨"
"defaultMessage": "{object} 생성"
},
"object.event.deleted": {
"defaultMessage": "{object}이(가) 삭제됨"
"defaultMessage": "{object} 삭제"
},
"object.event.disabled": {
"defaultMessage": "{object}이(가) 비활성화됨"
"defaultMessage": "{object} 비활성화"
},
"object.event.enabled": {
"defaultMessage": "{object}이(가) 활성화됨"
"defaultMessage": "{object} 활성화"
},
"object.event.renewed": {
"defaultMessage": "{object}이(가) 갱신됨"
"defaultMessage": "{object} 갱신"
},
"object.event.updated": {
"defaultMessage": "{object}이(가) 업데이트됨"
"defaultMessage": "{object} 수정"
},
"offline": {
"defaultMessage": "비활성화"
"defaultMessage": "오프라인"
},
"online": {
"defaultMessage": "활성화"
"defaultMessage": "온라인"
},
"options": {
"defaultMessage": "옵션"
@@ -495,7 +639,7 @@
"defaultMessage": "비밀번호 숨기기"
},
"password.show": {
"defaultMessage": "비밀번호 표시"
"defaultMessage": "비밀번호 보기"
},
"permissions.hidden": {
"defaultMessage": "숨김"
@@ -510,10 +654,10 @@
"defaultMessage": "모든 항목"
},
"permissions.visibility.title": {
"defaultMessage": "항목 표시 설정"
"defaultMessage": "항목 표시 범위"
},
"permissions.visibility.user": {
"defaultMessage": "내가 만든 항목만"
"defaultMessage": "본인이 만든 항목만"
},
"proxy-host": {
"defaultMessage": "프록시 호스트"
@@ -525,7 +669,7 @@
"defaultMessage": "프록시 호스트"
},
"proxy-hosts.count": {
"defaultMessage": "{count}개의 프록시 호스트"
"defaultMessage": "프록시 호스트 {count}개"
},
"public": {
"defaultMessage": "공개"
@@ -534,7 +678,7 @@
"defaultMessage": "리다이렉션 호스트"
},
"redirection-host.forward-domain": {
"defaultMessage": "전달할 도메인"
"defaultMessage": "리다이렉트할 도메인"
},
"redirection-host.forward-http-code": {
"defaultMessage": "HTTP 코드"
@@ -543,25 +687,25 @@
"defaultMessage": "리다이렉션 호스트"
},
"redirection-hosts.count": {
"defaultMessage": "{count}개의 리다이렉션 호스트"
"defaultMessage": "리다이렉션 호스트 {count}개"
},
"redirection-hosts.http-code.300": {
"defaultMessage": "300 Multiple Choices"
"defaultMessage": "300 여러 선택 항목"
},
"redirection-hosts.http-code.301": {
"defaultMessage": "301 Moved permanently"
"defaultMessage": "301 영구 이동"
},
"redirection-hosts.http-code.302": {
"defaultMessage": "302 Moved temporarily"
"defaultMessage": "302 임시 이동"
},
"redirection-hosts.http-code.303": {
"defaultMessage": "303 See other"
"defaultMessage": "303 기타 위치 보기"
},
"redirection-hosts.http-code.307": {
"defaultMessage": "307 Temporary redirect"
"defaultMessage": "307 임시 리다이렉션"
},
"redirection-hosts.http-code.308": {
"defaultMessage": "308 Permanent redirect"
"defaultMessage": "308 영구 리다이렉션"
},
"role.admin": {
"defaultMessage": "관리자"
@@ -597,7 +741,7 @@
"defaultMessage": "사용자 지정 HTML"
},
"settings.default-site.html.placeholder": {
"defaultMessage": "<!-- 여기에 사용자 정의 HTML 내용을 입력하세요. -->"
"defaultMessage": "<!-- 여기에 사용자 지정 HTML 내용을 입력하세요 -->"
},
"settings.default-site.redirect": {
"defaultMessage": "리다이렉트"
@@ -615,22 +759,22 @@
"defaultMessage": "SSL 인증서"
},
"stream": {
"defaultMessage": "호스트 스트림"
"defaultMessage": "스트림"
},
"stream.forward-host": {
"defaultMessage": "전달할 호스트"
},
"stream.forward-host.placeholder": {
"defaultMessage": "example.com / 10.0.0.1 / IPv6"
"defaultMessage": "example.com 또는 10.0.0.1 또는 2001:db8:3333:4444:5555:6666:7777:8888"
},
"stream.incoming-port": {
"defaultMessage": "수신 포트"
},
"streams": {
"defaultMessage": "호스트 스트림"
"defaultMessage": "스트림"
},
"streams.count": {
"defaultMessage": "{count}개의 호스트 스트림"
"defaultMessage": "스트림 {count}개"
},
"streams.tcp": {
"defaultMessage": "TCP"
@@ -660,10 +804,10 @@
"defaultMessage": "프로필 편집"
},
"user.full-name": {
"defaultMessage": "전체 이름"
"defaultMessage": "이름"
},
"user.login-as": {
"defaultMessage": "{name}으로 로그인"
"defaultMessage": "{name} 계정으로 로그인"
},
"user.logout": {
"defaultMessage": "로그아웃"
@@ -686,6 +830,9 @@
"user.switch-light": {
"defaultMessage": "라이트 모드로 전환"
},
"user.two-factor": {
"defaultMessage": "2단계 인증"
},
"username": {
"defaultMessage": "사용자 이름"
},
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more