Compare commits

...
149 Commits
Author SHA1 Message Date
Jamie Curnow 2cfd3395cf Adds integration tests for per path access lists, fixes ipv6 jsv,
and enforces host-wide access list when location access list is not set
2026-09-24 12:21:49 +10:00
jc21 c41ec008bb Merge pull request #5772 from drakhaw/develop
feat: per-path access lists, host logs modal
2026-09-24 08:38:14 +10:00
Dimas R. Wisnu 900bb1e480 fix: resolve merge conflicts breaking frontend CI
- LocationsFields: remove duplicated conflict block, integrate AccessField
  into the Row-based layout (fixes JSX parse error)
- ProxyHosts/Table: migrate to updated @tanstack/react-table API (useTable,
  createColumnHelper<Features, ProxyHost>)
- Add missing Estonian translations (action.logs, column.error)
- Re-sort en.json locale keys
2026-09-23 19:18:51 +07:00
Dimas Rakha Wisnu 633b653f79 Merge branch 'develop' into develop 2026-09-23 18:53:06 +07:00
jc21 58566c9d75 Merge branch 'master' into develop 2026-09-23 20:57:08 +10:00
jc21 c53f52ab51 Merge pull request #5878 from carlosalbertorg/feat/log-viewer
feat: Add a Logs viewer to the admin UI
2026-09-23 20:23:26 +10:00
carlosalbertorg 9445038b36 fix: Use forwarding_host instead of the renamed forward_ip column
The stream table's forward_ip column was renamed to forwarding_host
back in migration 20210423103500_stream_domain.js, so the stream log
source labels were always showing "undefined" for the forwarding
target. Spotted by @jc21 in review.
2026-09-23 07:00:19 -03:00
jc21 b1e0473a19 Merge pull request #5741 from addielaruee/fix/stream-ipv6-forward-host-brackets
fix(stream): bracket IPv6 forward host for valid nginx upstream
2026-09-23 14:29:27 +10:00
jc21 95f1b74757 Merge pull request #5862 from NginxProxyManager/dependabot/npm_and_yarn/frontend/dev-minor-updates-793a873af9
build(deps-dev): bump the dev-minor-updates group across 1 directory with 5 updates
2026-09-23 14:23:52 +10:00
jc21 8509e1b378 Merge pull request #5813 from vzagorovskiy/fix/regenerate-config-wrong-host-type
Use the row's own model and host type in regenerate-config
2026-09-23 14:11:52 +10:00
Jamie Curnow 5f35e4916f Bump version 2026-09-23 14:10:29 +10:00
jc21 da12ee5b0c Merge pull request #5814 from shawnhank/fix/dns-credentials-lifetime
Remove DNS provider credentials from disk after certbot runs
2026-09-23 14:08:29 +10:00
jc21 61af8e23d8 Merge pull request #5829 from NginxProxyManager/dependabot/npm_and_yarn/backend/qs-6.16.0
build(deps): bump qs from 6.15.3 to 6.16.0 in /backend
2026-09-23 14:06:28 +10:00
jc21 c4e421e679 Merge pull request #5830 from NginxProxyManager/dependabot/npm_and_yarn/test/humanfs/node-0.16.8
Bump @humanfs/node from 0.16.7 to 0.16.8 in /test
2026-09-23 14:06:13 +10:00
jc21 1e18bf9212 Merge pull request #5831 from cloudchristoph/fix/certbot-dns-azure-modern
fix(certbot): switch the Azure DNS plugin to certbot-dns-azure-modern
2026-09-23 14:06:00 +10:00
jc21 af1d4dd60f Merge pull request #5854 from hurole/patch-1
feat: add Tencent Cloud EdgeOne DNS provider
2026-09-23 13:59:16 +10:00
dependabot[bot] f47d85da9e build(deps-dev): bump the dev-minor-updates group across 1 directory with 5 updates
Bumps the dev-minor-updates group with 5 updates in the /frontend directory:

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



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

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

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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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


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

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

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


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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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


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

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

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

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

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

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

### Details

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

    ### Credentials Template

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

### Verification

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

Muudatuse tegi Aarmaa IT - Siim Aarmaa

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 13:04:01 +00:00
dependabot[bot] aa211ea423 Bump axios from 1.19.0 to 1.20.0 in /test
Bumps [axios](https://github.com/axios/axios) from 1.19.0 to 1.20.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.19.0...v1.20.0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 13:57:48 +00:00
Jamie Curnow 0d832092a5 Only modify files in startup when they aren't mounted 2026-06-02 22:09:21 +10:00
Yassine Damiri 6629abed32 Use fs module for credential file operations
Refactor (from the review) credential file handling to use fs module for directory creation and file writing
2026-06-01 17:02:19 +02:00
Yassine DAMIRI 46bf3274d8 fix: Changed order of escape to prevent RCE 2026-04-20 01:00:43 +02:00
7heMech 18fc3fe770 Merge branch 'develop' into develop 2026-03-02 20:45:41 +02:00
7heMech e72dfc3bfd Add reuseport to improve performance and reduce packet drops 2026-02-04 12:14:10 +00:00
180 changed files with 8465 additions and 2863 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
stale-issue-label: 'stale'
stale-pr-label: 'stale'
+1 -1
View File
@@ -1 +1 @@
2.15.0
2.16.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.15.0-green.svg?style=for-the-badge">
<img src="https://img.shields.io/badge/version-2.16.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>
+2 -2
View File
@@ -7,8 +7,8 @@ Older versions are not actively maintained.
| Version | Supported |
| ------- | --------- |
| 2.15.x (latest) | :white_check_mark: |
| < 2.15.0 | :x: |
| 2.16.x (latest) | :white_check_mark: |
| < 2.16.0 | :x: |
Docker images: `jc21/nginx-proxy-manager:latest`, `jc21/nginx-proxy-manager:2`
+72 -89
View File
@@ -1,91 +1,74 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"!**/dist/**/*"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
[
"npm:*",
"npm:*/**"
],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
[
"/src/*",
"/src/**"
],
[
"/**"
],
[
"#*",
"#*/**"
],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
["npm:*", "npm:*/**"],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
["/src/*", "/src/**"],
["/**"],
["#*", "#*/**"],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
}
+23 -7
View File
@@ -32,12 +32,12 @@
"version": ">=0.1.0"
},
"azure": {
"credentials": "# This plugin supported API authentication using either Service Principals or utilizing a Managed Identity assigned to the virtual machine.\n# Regardless which authentication method used, the identity will need the DNS Zone Contributor” role assigned to it.\n# As multiple Azure DNS Zones in multiple resource groups can exist, the config file needs a mapping of zone to resource group ID. Multiple zones -> ID mappings can be listed by using the key dns_azure_zoneX where X is a unique number. At least 1 zone mapping is required.\n\n# Using a service principal (option 1)\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = E-xqXU83Y-jzTI6xe9fs2YC~mck3ZzUih9\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n\n# Using used assigned MSI (option 2)\n# dns_azure_msi_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\n\n# Using system assigned MSI (option 3)\n# dns_azure_msi_system_assigned = true\n\n# Zones (at least one always required)\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/dns1\ndns_azure_zone2 = example.org:/subscriptions/99800903-fb14-4992-9aff-12eaf2744622/resourceGroups/dns2",
"dependencies": "azure-mgmt-dns==8.2.0",
"credentials": "# Docs: https://cloudchristoph.github.io/certbot-dns-azure-modern/\n# Service principal with the \"DNS Zone Contributor\" role on the DNS zone\ndns_azure_sp_client_id = 912ce44a-0156-4669-ae22-c16a17d34ca5\ndns_azure_sp_client_secret = example-client-secret-not-real\ndns_azure_tenant_id = ed1090f3-ab18-4b12-816c-599af8a88cf7\n# One line per DNS zone, format ZONE_NAME:RESOURCE_GROUP_ID\ndns_azure_zone1 = example.com:/subscriptions/c135abce-d87d-48df-936c-15596c6968a5/resourceGroups/rg-dns",
"dependencies": "",
"full_plugin_name": "dns-azure",
"name": "Azure",
"package_name": "certbot-dns-azure",
"version": "~=2.6.1"
"package_name": "certbot-dns-azure-modern",
"version": "~=2.8.0"
},
"baidu": {
"credentials": "dns_baidu_access_key = 12345678\ndns_baidu_secret_key = 1234567890abcdef1234567890abcdef",
@@ -231,6 +231,14 @@
"package_name": "certbot-plugin-edgedns",
"version": "~=0.1.0"
},
"edgeone": {
"credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY",
"dependencies": "",
"full_plugin_name": "dns-edgeone",
"name": "Tencent Cloud EdgeOne",
"package_name": "certbot-dns-edgeone",
"version": ">=0.1.0"
},
"eurodns": {
"credentials": "dns_eurodns_applicationId = myuser\ndns_eurodns_apiKey = mysecretpassword\ndns_eurodns_endpoint = https://rest-api.eurodns.com/dns-zones/",
"dependencies": "",
@@ -629,7 +637,7 @@
"full_plugin_name": "dns-tencentcloud",
"name": "Tencent Cloud",
"package_name": "certbot-dns-tencentcloud",
"version": "~=2.0.2"
"version": "~=2.1.1"
},
"timeweb": {
"credentials": "dns_timeweb_api_key = XXXXXXXXXXXXXXXXXXX",
@@ -637,7 +645,7 @@
"full_plugin_name": "dns-timeweb",
"name": "Timeweb Cloud",
"package_name": "certbot-dns-timeweb",
"version": "~=1.0.1"
"version": "~=2.0.0"
},
"transip": {
"credentials": "dns_transip_username = my_username\ndns_transip_key_file = /etc/letsencrypt/transip-rsa.key",
@@ -661,7 +669,7 @@
"full_plugin_name": "dns-websupport",
"name": "Websupport.sk",
"package_name": "certbot-dns-websupport",
"version": "~=2.0.1"
"version": "~=5.0.0"
},
"wedos": {
"credentials": "dns_wedos_user = <wedos_registration>\ndns_wedos_auth = <wapi_password>",
@@ -686,5 +694,13 @@
"name": "RcodeZero",
"package_name": "certbot-dns-rcode0",
"version": "~=0.0.0.2"
},
"lws": {
"credentials": "dns_lws_login = 123456\ndns_lws_api_key = YOUR_API_KEY",
"dependencies": "",
"full_plugin_name": "dns-lws",
"name": "LWS",
"package_name": "certbot-dns-lws",
"version": "~=1.0.0"
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import knex from "knex";
import {configGet, configHas} from "./lib/config.js";
import { configGet, configHas } from "./lib/config.js";
let instance = null;
@@ -23,8 +23,8 @@ const generateDbConfig = () => {
user: cfg.user,
password: cfg.password,
database: cfg.name,
port: cfg.port,
...(cfg.ssl ? { ssl: cfg.ssl } : {})
port: cfg.port,
...(cfg.ssl ? { ssl: cfg.ssl } : {}),
},
migrations: {
tableName: "migrations",
@@ -37,6 +37,6 @@ const getInstance = () => {
instance = knex(generateDbConfig());
}
return instance;
}
};
export default getInstance;
+7 -11
View File
@@ -161,12 +161,12 @@ const internal2fa = {
}
const result = await verify({
token: code,
secret: auth.meta.totp_secret,
guardrails: createGuardrails({
MIN_SECRET_BYTES: 10,
}),
});
token: code,
secret: auth.meta.totp_secret,
guardrails: createGuardrails({
MIN_SECRET_BYTES: 10,
}),
});
if (!result.valid) {
throw new errs.AuthError("Invalid verification code");
@@ -288,11 +288,7 @@ const internal2fa = {
},
getUserPasswordAuth: async (userId) => {
const auth = await authModel
.query()
.where("user_id", userId)
.andWhere("type", "password")
.first();
const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first();
if (!auth) {
throw new errs.ItemNotFoundError("Auth not found");
+101 -43
View File
@@ -2,7 +2,9 @@ import fs from "node:fs";
import batchflow from "batchflow";
import _ from "lodash";
import errs from "../lib/error.js";
import { isMysql, isPostgres } from "../lib/config.js";
import utils from "../lib/utils.js";
import db from "../db.js";
import { access as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import accessListAuthModel from "../models/access_list_auth.js";
@@ -15,6 +17,38 @@ const omissions = () => {
return ["is_deleted"];
};
/**
* Find proxy hosts that reference an access list in their locations JSON.
*
* @param {Integer} accessListId
* @returns {Promise<Array>}
*/
const getProxyHostsUsingAccessListInLocations = async (accessListId) => {
let result;
if (isMysql()) {
const searchObj = JSON.stringify([{ access_list_id: accessListId }]);
result = await db().raw(
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND JSON_CONTAINS(locations, ?, ?)",
[searchObj, "$"],
);
} else if (isPostgres()) {
result = await db().raw(
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations::jsonb @> ?::jsonb",
[JSON.stringify([{ access_list_id: accessListId }])],
);
} else {
result = await db().raw(
"SELECT id FROM proxy_host WHERE is_deleted = 0 AND locations LIKE ?",
[`%"access_list_id":${accessListId}%`],
);
}
// knex raw() returns [rows, metadata] for MySQL, { rows } for Postgres and rows for SQLite
if (!Array.isArray(result)) {
return result?.rows || [];
}
return (Array.isArray(result[0]) ? result[0] : result) || [];
};
const internalAccessList = {
/**
* @param {Access} access
@@ -66,7 +100,7 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
},
true // skip masking
true, // skip masking
);
// Audit log
@@ -180,13 +214,31 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
},
true // skip masking
true, // skip masking
);
await internalAccessList.build(freshRow)
await internalAccessList.build(freshRow);
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
}
// Also regenerate configs for proxy hosts that reference this access list in their locations
const locationHostRows = await getProxyHostsUsingAccessListInLocations(data.id);
if (locationHostRows?.length) {
const locationHostIds = locationHostRows.map((r) => r.id).filter((id) => {
// Exclude hosts already regenerated above
return !freshRow.proxy_hosts?.find((h) => h.id === id);
});
if (locationHostIds.length) {
const locationHosts = await proxyHostModel.query()
.where("is_deleted", 0)
.whereIn("id", locationHostIds)
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", locationHosts);
}
}
await internalNginx.reload();
return internalAccessList.maskItems(freshRow);
},
@@ -202,17 +254,13 @@ const internalAccessList = {
*/
get: async (access, data, skipMasking) => {
const thisData = data || {};
const accessData = await access.can("access_lists:get", thisData.id)
const accessData = await access.can("access_lists:get", thisData.id);
const query = accessListModel
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
"proxy_host.is_deleted",
"=",
0,
);
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.andWhere("access_list.id", thisData.id)
@@ -267,28 +315,41 @@ const internalAccessList = {
// 4. audit log
// 1. update row to be deleted
await accessListModel
.query()
.where("id", row.id)
.patch({
is_deleted: 1,
});
await accessListModel.query().where("id", row.id).patch({
is_deleted: 1,
});
// 2. update any proxy hosts that were using it (ignoring permissions)
if (row.proxy_hosts) {
await proxyHostModel
const affectedHostIds = new Set((row.proxy_hosts || []).map((h) => h.id));
if (affectedHostIds.size) {
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
}
// Also clear it from any proxy host locations using it, these will then inherit the host's access list
const locationHostRows = await getProxyHostsUsingAccessListInLocations(row.id);
for (const { id: hostId } of locationHostRows) {
const host = await proxyHostModel.query().where("id", hostId).first();
if (host?.locations?.some((loc) => loc.access_list_id === row.id)) {
const updatedLocations = host.locations.map((loc) => {
if (loc.access_list_id === row.id) {
return { ...loc, access_list_id: 0 };
}
return loc;
});
await proxyHostModel.query().where("id", hostId).patch({ locations: updatedLocations });
affectedHostIds.add(hostId);
}
}
// 3. reconfigure those hosts from fresh rows, then reload nginx
if (affectedHostIds.size) {
const affectedHosts = await proxyHostModel
.query()
.where("access_list_id", "=", row.id)
.patch({ access_list_id: 0 });
// 3. reconfigure those hosts, then reload nginx
// set the access_list_id to zero for these items
row.proxy_hosts.map((_val, idx) => {
row.proxy_hosts[idx].access_list_id = 0;
return true;
});
await internalNginx.bulkGenerateConfigs("proxy_host", row.proxy_hosts);
.where("is_deleted", 0)
.whereIn("id", [...affectedHostIds])
.allowGraph(proxyHostModel.defaultAllowGraph)
.withGraphFetched("[owner, certificate, access_list.[clients,items]]");
await internalNginx.bulkGenerateConfigs("proxy_host", affectedHosts);
}
await internalNginx.reload();
@@ -325,11 +386,7 @@ const internalAccessList = {
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
"proxy_host.is_deleted",
"=",
0,
);
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.groupBy("access_list.id")
@@ -371,10 +428,7 @@ const internalAccessList = {
* @returns {Promise}
*/
getCount: async (userId, visibility) => {
const query = accessListModel
.query()
.count("id as count")
.where("is_deleted", 0);
const query = accessListModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") {
query.andWhere("owner_user_id", userId);
@@ -436,20 +490,24 @@ const internalAccessList = {
}
// 2. create empty access file
fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
// 3. generate password for each user
if (list.items.length) {
await new Promise((resolve, reject) => {
batchflow(list.items).sequential()
batchflow(list.items)
.sequential()
.each((_i, item, next) => {
if (item.password?.length) {
logger.info(`Adding: ${item.username}`);
utils.execFile('openssl', ['passwd', '-apr1', item.password])
utils
.execFile("openssl", ["passwd", "-apr1", item.password])
.then((res) => {
try {
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'});
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
encoding: "utf8",
});
} catch (err) {
reject(err);
}
@@ -471,7 +529,7 @@ const internalAccessList = {
});
});
}
}
}
},
};
export default internalAccessList;
+1 -6
View File
@@ -3,7 +3,6 @@ import { castJsonIfNeed } from "../lib/helpers.js";
import auditLogModel from "../models/audit-log.js";
const internalAuditLog = {
/**
* All logs
*
@@ -46,11 +45,7 @@ const internalAuditLog = {
get: async (access, data) => {
await access.can("auditlog:list");
const query = auditLogModel
.query()
.andWhere("id", data.id)
.allowGraph("[user]")
.first();
const query = auditLogModel.query().andWhere("id", data.id).allowGraph("[user]").first();
if (typeof data.expand !== "undefined" && data.expand !== null) {
query.withGraphFetched(`[${data.expand.join(", ")}]`);
+63 -6
View File
@@ -881,10 +881,20 @@ const internalCertificate = {
const result = await utils.execFile(certbotCommand, args, adds.opts);
logger.info(result);
return result;
} catch (err) {
// Don't fail if file does not exist, so no need for action in the callback
} finally {
// Remove the credentials file whether certbot succeeded or failed.
//
// This cleanup used to sit in a catch block, so it only ran when issuance FAILED.
// A certificate that issued successfully left its DNS provider API credentials in
// /etc/letsencrypt/credentials for the entire life of that certificate. Nothing
// reads the file between certbot runs, so there is no reason to keep it:
// renewLetsEncryptSslWithDnsChallenge() writes it again immediately before each
// renewal.
//
// unlink is fire-and-forget with an empty callback. If the file is already gone
// that is the end state we wanted anyway, and a missing file must never turn a
// successful issuance into a failure.
fs.unlink(credentialsLocation, () => {});
throw err;
}
},
@@ -981,6 +991,43 @@ const internalCertificate = {
`Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
);
// certbot reads the DNS credentials back from the path recorded in the renewal config
// it wrote at issuance time, for example:
//
// authenticator = dns-cloudflare
// dns_cloudflare_credentials = /etc/letsencrypt/credentials/credentials-27
//
// so the file has to be present for the duration of this run. Write it here and remove
// it again below rather than leaving it on disk between renewals.
//
// Leaving it is an avoidable exposure. Anything running as root - a compromised
// process, a script, malware - can read the token and use it to issue valid Let's
// Encrypt certificates for the domain. Those certificates are genuinely trusted, so
// traffic presented with them passes TLS inspection, IDS/IPS and DLP that would
// otherwise flag it, and an exfiltration path built on them looks like ordinary
// HTTPS. The exposure window should be one certbot run, not the life of the
// certificate.
//
// The value is not on the certificate object we were handed: renew() sources that from
// internalCertificate.get(), which pipes the row through utils.omitRow(omissions()) so
// meta.dns_provider_credentials can never travel out over the API. Read the row from
// the model directly to get at it.
const row = await certificateModel.query().where("id", certificate.id).first();
const credentials = row?.meta?.dns_provider_credentials;
const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
if (credentials) {
fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true });
fs.writeFileSync(credentialsLocation, credentials, { mode: 0o600 });
} else {
// Nothing stored to write. A certificate issued under the previous behaviour may
// still have its file on disk; leave it be and let certbot decide. Throwing here
// would break a renewal that would otherwise have succeeded.
logger.warn(
`No stored DNS credentials for Cert #${certificate.id}; relying on any existing ${credentialsLocation}`,
);
}
const args = [
"renew",
"--force-renewal",
@@ -1008,9 +1055,19 @@ const internalCertificate = {
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
const result = await utils.execFile(certbotCommand, args, adds.opts);
logger.info(result);
return result;
try {
const result = await utils.execFile(certbotCommand, args, adds.opts);
logger.info(result);
return result;
} finally {
// Only clean up a file we put there ourselves. If `credentials` came back empty we
// wrote nothing, and an older file left on disk by the previous behaviour is the
// only thing keeping that certificate renewable - deleting it would break the next
// run for no gain.
if (credentials) {
fs.unlink(credentialsLocation, () => {});
}
}
},
/**
+25 -37
View File
@@ -54,9 +54,7 @@ const internalDeadHost = {
thisData.advanced_config = "";
}
const row = await deadHostModel.query()
.insertAndFetch(thisData)
.then(utils.omitRow(omissions()));
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
// Add to audit log
await internalAuditLog.add(access, {
@@ -153,12 +151,8 @@ const internalDeadHost = {
thisData = internalHost.cleanSslHstsData(thisData, row);
// do the row update
await deadHostModel
.query()
.where({id: data.id})
.patch(data);
await deadHostModel.query().where({ id: data.id }).patch(data);
// Add to audit log
await internalAuditLog.add(access, {
@@ -168,15 +162,18 @@ const internalDeadHost = {
meta: thisData,
});
const thisRow = await internalDeadHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate"],
});
const thisRow = await internalDeadHost.get(access, {
id: thisData.id,
expand: ["owner", "certificate"],
});
// Configure nginx
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", row);
row.meta = newMeta;
if (!thisRow.enabled) {
// No need to add nginx config if host is disabled
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
}
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", thisRow);
thisRow.meta = newMeta;
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
},
@@ -224,18 +221,15 @@ const internalDeadHost = {
* @returns {Promise}
*/
delete: async (access, data) => {
await access.can("dead_hosts:delete", data.id)
await access.can("dead_hosts:delete", data.id);
const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
await deadHostModel
.query()
.where("id", row.id)
.patch({
is_deleted: 1,
});
await deadHostModel.query().where("id", row.id).patch({
is_deleted: 1,
});
// Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row);
@@ -259,7 +253,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
enable: async (access, data) => {
await access.can("dead_hosts:update", data.id)
await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, {
id: data.id,
expand: ["certificate", "owner"],
@@ -273,12 +267,9 @@ const internalDeadHost = {
row.enabled = 1;
await deadHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
});
await deadHostModel.query().where("id", row.id).patch({
enabled: 1,
});
// Configure nginx
await internalNginx.configure(deadHostModel, "dead_host", row);
@@ -301,7 +292,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
disable: async (access, data) => {
await access.can("dead_hosts:update", data.id)
await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
@@ -312,12 +303,9 @@ const internalDeadHost = {
row.enabled = 0;
await deadHostModel
.query()
.where("id", row.id)
.patch({
enabled: 0,
});
await deadHostModel.query().where("id", row.id).patch({
enabled: 0,
});
// Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row);
@@ -342,7 +330,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
getAll: async (access, expand, searchQuery) => {
const accessData = await access.can("dead_hosts:list")
const accessData = await access.can("dead_hosts:list");
const query = deadHostModel
.query()
.where("is_deleted", 0)
+229
View File
@@ -0,0 +1,229 @@
import fs from "node:fs";
import errs from "../lib/error.js";
import internalDeadHost from "./dead-host.js";
import internalProxyHost from "./proxy-host.js";
import internalRedirectionHost from "./redirection-host.js";
import internalStream from "./stream.js";
const SYSTEM_LOG_FILE = "/data/logs/backend.log";
const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log";
// Matches the access_log/error_log paths written by the nginx templates
// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf).
// This is a fixed, server-side lookup table - a host log path is always derived
// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied
// path or filename, so there is no path-traversal surface here.
const HOST_FILE_PREFIX = {
proxy: "proxy-host",
redirection: "redirection-host",
dead: "dead-host",
stream: "stream",
};
const DEFAULT_LINES = 200;
const MAX_LINES = 1000;
const CHUNK_SIZE = 64 * 1024;
// Never scan further back than this, regardless of how many lines were requested,
// so a huge or pathological log file can't turn a single request into unbounded I/O.
const MAX_SCAN_BYTES = 5 * 1024 * 1024;
/**
* Reads at most `maxLines` lines from the end of a file, without loading the
* whole file into memory. Reads backwards in fixed-size chunks until enough
* newlines have been seen, the start of the file is reached, or the hard
* MAX_SCAN_BYTES ceiling is hit.
*
* @param {String} filePath
* @param {Number} maxLines
* @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>}
*/
const readLastLines = async (filePath, maxLines) => {
let handle;
try {
handle = await fs.promises.open(filePath, "r");
const stat = await handle.stat();
const { size } = stat;
if (size === 0) {
return { lines: [], size: 0, truncated: false };
}
let position = size;
let scanned = 0;
let newlineCount = 0;
const chunks = [];
while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) {
const readSize = Math.min(CHUNK_SIZE, position);
position -= readSize;
const buffer = Buffer.alloc(readSize);
await handle.read(buffer, 0, readSize, position);
scanned += readSize;
for (let i = buffer.length - 1; i >= 0; i--) {
if (buffer[i] === 0x0a) newlineCount++;
}
chunks.unshift(buffer);
}
const truncated = position > 0 && scanned >= MAX_SCAN_BYTES;
// If we didn't start reading from byte 0, the first line in our buffer is only
// partial *unless* it happens that `position` landed exactly on a line boundary
// (the byte right before it is a newline) - check that one byte to avoid
// silently dropping a perfectly valid line.
let startsOnLineBoundary = position === 0;
if (position > 0) {
const boundaryByte = Buffer.alloc(1);
await handle.read(boundaryByte, 0, 1, position - 1);
startsOnLineBoundary = boundaryByte[0] === 0x0a;
}
const text = Buffer.concat(chunks).toString("utf8");
const allLines = text.split("\n");
if (!startsOnLineBoundary && allLines.length > 0) {
allLines.shift();
}
// Drop the trailing empty element caused by a final trailing newline.
if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
allLines.pop();
}
return { lines: allLines.slice(-maxLines), size, truncated };
} finally {
if (handle) {
await handle.close();
}
}
};
/**
* Resolves a validated {type, host_type, host_id, channel} selection to the
* fixed, absolute path of the log file on disk. Throws if the combination
* isn't a recognised source.
*
* `channel` ("access" | "error") picks which of the two log files nginx writes
* per host - it's deliberately not named "stream" to avoid confusion with the
* "stream" host_type (TCP/UDP stream hosts).
*
* @param {Object} data
* @returns {String}
*/
const resolveFilePath = (data) => {
switch (data.type) {
case "system":
return SYSTEM_LOG_FILE;
case "letsencrypt":
return LETSENCRYPT_LOG_FILE;
case "host": {
const prefix = HOST_FILE_PREFIX[data.host_type];
if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) {
throw new errs.ValidationError("Invalid host log source");
}
return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`;
}
default:
throw new errs.ItemNotFoundError(data.type);
}
};
/**
* @param {Array} rows
* @returns {Array}
*/
const toHostOptions = (rows) =>
rows.map((row) => ({
id: row.id,
label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`,
}));
const internalLogViewer = {
/**
* Lists the log sources available for the log viewer: the system (backend)
* log, the Let's Encrypt (certbot) log, and one entry per host the caller
* can see, for each host type.
*
* @param {Access} access
* @returns {Promise}
*/
listSources: async (access) => {
await access.can("logs:list");
const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([
internalProxyHost.getAll(access),
internalRedirectionHost.getAll(access),
internalDeadHost.getAll(access),
internalStream.getAll(access),
]);
return {
system: { label: "System" },
letsencrypt: { label: "Let's Encrypt" },
hosts: {
proxy: toHostOptions(proxyHosts),
redirection: toHostOptions(redirectionHosts),
dead: toHostOptions(deadHosts),
stream: streams.map((row) => ({
id: row.id,
label: `Port ${row.incoming_port}${row.forwarding_host}:${row.forwarding_port}`,
})),
},
};
},
/**
* Returns the last N lines of the requested log source, optionally
* filtered by level and/or a plain-text search term.
*
* @param {Access} access
* @param {Object} data
* @param {String} data.type "system" | "letsencrypt" | "host"
* @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream"
* @param {Number} [data.host_id]
* @param {String} [data.channel] "access" | "error"
* @param {Number} [data.lines]
* @param {String} [data.level]
* @param {String} [data.search]
* @returns {Promise}
*/
tail: async (access, data) => {
await access.can("logs:list");
const filePath = resolveFilePath(data);
const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES);
let result;
try {
result = await readLastLines(filePath, lines);
} catch (err) {
if (err.code === "ENOENT") {
return { lines: [], size: 0, truncated: false, exists: false };
}
throw err;
}
let outputLines = result.lines;
// Only the system log is written in our own "LEVEL [scope]" format - level
// filtering on nginx/certbot lines would just match nothing and look like an
// empty log, so the filter is a no-op for any other source.
if (data.type === "system" && data.level) {
const needle = ` ${data.level.toUpperCase().padEnd(7)} `;
outputLines = outputLines.filter((line) => line.includes(needle));
}
if (data.search) {
const needle = data.search.toLowerCase();
outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle));
}
return {
lines: outputLines,
size: result.size,
truncated: result.truncated,
exists: true,
};
},
};
export default internalLogViewer;
+45 -7
View File
@@ -1,10 +1,12 @@
import fs from "node:fs";
import net from "node:net";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import _ from "lodash";
import errs from "../lib/error.js";
import utils from "../lib/utils.js";
import { debug, nginx as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -34,7 +36,7 @@ const internalNginx = {
// We're deleting this config regardless.
// Don't throw errors, as the file may not exist at all
// Delete the .err file too
return internalNginx.deleteConfig(host_type, host, false, true);
return internalNginx.deleteConfig(host_type, host, true);
})
.then(() => {
return internalNginx.generateConfig(host_type, host);
@@ -83,10 +85,12 @@ const internalNginx = {
meta: combined_meta,
})
.then(() => {
internalNginx.renameConfigAsError(host_type, host);
// Keep the failed config as a .err file for inspection
return internalNginx.renameConfigAsError(host_type, host);
})
.then(() => {
return internalNginx.deleteConfig(host_type, host, true);
// The rename removed the live config already, don't touch the .err file
return internalNginx.deleteConfig(host_type, host, false);
});
});
})
@@ -165,6 +169,24 @@ const internalNginx = {
host.locations[i],
);
// A location with its own access list overrides the host's,
// otherwise it inherits the host's access list
let locationAccessList = null;
if (locationCopy.access_list_id > 0 && locationCopy.access_list_id !== host.access_list?.id) {
locationAccessList = await accessListModel
.query()
.where("is_deleted", 0)
.andWhere("id", locationCopy.access_list_id)
.withGraphFetched("[clients,items]")
.first();
}
if (locationAccessList) {
locationCopy.access_list = locationAccessList;
} else {
locationCopy.access_list_id = host.access_list_id;
locationCopy.access_list = host.access_list;
}
if (locationCopy.forward_host.indexOf("/") > -1) {
const splitted = locationCopy.forward_host.split("/");
@@ -176,7 +198,9 @@ const internalNginx = {
}
};
locationRendering().then(() => resolve(renderedLocations));
locationRendering()
.then(() => resolve(renderedLocations))
.catch(reject);
});
},
@@ -217,10 +241,21 @@ const internalNginx = {
}
// For redirection hosts, if the scheme is not http or https, set it to $scheme
if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
if (
nice_host_type === "redirection_host" &&
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
) {
host.forward_scheme = "$scheme";
}
// A stream forwarding to an IPv6 literal must have the address wrapped in
// square brackets before nginx appends ":<port>". Without the brackets nginx
// reads the trailing ":<port>" as part of the address and rejects the upstream
// ("invalid port in upstream"), so the stream saves but never activates (#5740).
if (nice_host_type === "stream" && net.isIPv6(host.forwarding_host)) {
host.forwarding_host = `[${host.forwarding_host}]`;
}
if (host.locations) {
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
origLocations = [].concat(host.locations);
@@ -257,6 +292,9 @@ const internalNginx = {
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));
});
});
},
@@ -375,8 +413,8 @@ const internalNginx = {
const config_file_err = `${config_file}.err`;
return new Promise((resolve /*, reject*/) => {
fs.unlink(config_file, () => {
// ignore result, continue
fs.unlink(config_file_err, () => {
// ignore result, a previous .err file may not exist
fs.rename(config_file, config_file_err, () => {
// also ignore result, as this is a debugging informative file anyway
resolve();
+64 -67
View File
@@ -83,28 +83,28 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list.[clients,items]"],
});
})
.then((row) => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row).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;
});
})
.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;
});
});
});
},
/**
@@ -202,24 +202,24 @@ const internalProxyHost = {
});
});
})
.then(() => {
return internalProxyHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"],
})
.then((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());
});
});
});
});
},
/**
@@ -326,39 +326,36 @@ const internalProxyHost = {
expand: ["certificate", "owner", "access_list"],
});
})
.then((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;
return proxyHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
})
.then(() => {
// Configure nginx
return internalNginx.configure(proxyHostModel, "proxy_host", row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: "enabled",
object_type: "proxy-host",
object_id: row.id,
meta: _.omit(row, omissions()),
});
});
})
.then(() => {
return true;
await proxyHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
});
// 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()),
});
return true;
});
},
/**
+4 -14
View File
@@ -38,11 +38,7 @@ export default {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
const auth = await authModel
.query()
.where("user_id", "=", user.id)
.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);
@@ -50,10 +46,7 @@ export default {
const valid = await auth.verifyPassword(data.secret);
if (!valid) {
throw new errs.AuthError(
ERROR_MESSAGE_INVALID_AUTH,
ERROR_MESSAGE_INVALID_AUTH_I18N,
);
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) {
@@ -171,7 +164,7 @@ export default {
}
// Check scope
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
if (tokenData.scope?.[0] !== "2fa-challenge") {
throw new errs.AuthError("Invalid challenge token");
}
@@ -183,10 +176,7 @@ export default {
// Verify 2FA code
const valid = await twoFactor.verifyForLogin(userId, code);
if (!valid) {
throw new errs.AuthError(
ERROR_MESSAGE_INVALID_2FA,
ERROR_MESSAGE_INVALID_2FA_I18N,
);
throw new errs.AuthError(ERROR_MESSAGE_INVALID_2FA, ERROR_MESSAGE_INVALID_2FA_I18N);
}
// Create full token
+14 -6
View File
@@ -257,11 +257,9 @@ const internalUser = {
},
deleteAll: async () => {
await userModel
.query()
.patch({
is_deleted: 1,
});
await userModel.query().patch({
is_deleted: 1,
});
},
/**
@@ -390,11 +388,21 @@ const internalUser = {
.andWhere("type", data.type)
.first()
.then((existing_auth) => {
// Stamped here rather than read off modified_on, because it is compared against a
// token's `iat` and the two only line up when the same clock writes both. The
// database clock is a different one: with the app on one timezone and the database
// on another, its timestamps come back hours away from where Node thinks it is.
const password_changed_at = Math.floor(Date.now() / 1000);
if (existing_auth) {
// patch
const meta = existing_auth.meta || {};
meta.password_changed_at = password_changed_at;
return authModel.query().where("user_id", user.id).andWhere("type", data.type).patch({
type: data.type, // This is required for the model to encrypt on save
secret: data.secret,
meta,
});
}
// insert
@@ -402,7 +410,7 @@ const internalUser = {
user_id: user.id,
type: data.type,
secret: data.secret,
meta: {},
meta: { password_changed_at },
});
})
.then(() => {
+11 -11
View File
@@ -1,19 +1,19 @@
module.exports = {
development: {
client: 'mysql2',
client: "mysql2",
migrations: {
tableName: 'migrations',
stub: 'lib/migrate_template.js',
directory: 'migrations'
}
tableName: "migrations",
stub: "lib/migrate_template.js",
directory: "migrations",
},
},
production: {
client: 'mysql2',
client: "mysql2",
migrations: {
tableName: 'migrations',
stub: 'lib/migrate_template.js',
directory: 'migrations'
}
}
tableName: "migrations",
stub: "lib/migrate_template.js",
directory: "migrations",
},
},
};
+24 -5
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
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 TokenModel from "../models/token.js";
import userModel from "../models/user.js";
@@ -80,6 +81,22 @@ export default function (tokenString) {
if (!ok) {
throw new errs.AuthError("Invalid token scope for User");
}
// A token issued before the password was last changed is no longer valid: taking an account
// back from whoever has the old password has to end the sessions that password opened.
const auth = await authModel
.query()
.where("user_id", "=", user.id)
.where("type", "=", "password")
.first();
// Both sides come from the same clock and in the same unit, whole seconds since
// the epoch: `setPassword` stamps the marker and `jsonwebtoken` stamps `iat`.
const changedAt = auth?.meta?.password_changed_at;
if (changedAt && typeof tokenData.iat === "number" && tokenData.iat < changedAt) {
throw new errs.TokenRevokedError("Token was issued before the password was changed");
}
initialised = true;
userRoles = user.roles;
permissions = user.permissions;
@@ -119,10 +136,7 @@ export default function (tokenString) {
// Proxy Hosts
case "proxy_hosts": {
const query = proxyHostModel
.query()
.select("id")
.andWhere("is_deleted", 0);
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
@@ -271,7 +285,12 @@ export default function (tokenString) {
err.permission = permission;
err.permission_data = data;
logger.error(permission, data, err.message);
throw errs.PermissionError("Permission Denied", err);
// A revoked token is not a permission problem, and the client can tell: the frontend
// clears the session on a 401 and on nothing else.
if (err instanceof errs.TokenRevokedError) {
throw err;
}
throw new errs.PermissionError("Permission Denied", err);
}
},
};
+7
View File
@@ -0,0 +1,7 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
+37 -17
View File
@@ -2,13 +2,13 @@ import fs from "node:fs";
import NodeRSA from "node-rsa";
import { global as logger } from "../logger.js";
const keysFile = '/data/keys.json';
const mysqlEngine = 'mysql2';
const postgresEngine = 'pg';
const sqliteClientName = 'better-sqlite3';
const keysFile = "/data/keys.json";
const mysqlEngine = "mysql2";
const postgresEngine = "pg";
const sqliteClientName = "better-sqlite3";
// Not used for new setups anymore but may exist in legacy setups
const legacySqliteClientName = 'sqlite3';
const legacySqliteClientName = "sqlite3";
let instance = null;
@@ -40,14 +40,20 @@ const configure = () => {
}
}
const toBool = (v) => /^(1|true|yes|on)$/i.test((v || '').trim());
const toBool = (v) => /^(1|true|yes|on)$/i.test((v || "").trim());
const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized = process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined ? true : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity = process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined ? true : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized =
process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity =
process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
if (envMysqlHost && envMysqlUser && envMysqlName) {
// we have enough mysql creds to go with mysql
logger.info("Using MySQL configuration");
@@ -58,8 +64,10 @@ const configure = () => {
port: process.env.DB_MYSQL_PORT || 3306,
user: envMysqlUser,
password: process.env.DB_MYSQL_PASSWORD,
name: envMysqlName,
ssl: envMysqlSSL ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } : false,
name: envMysqlName,
ssl: envMysqlSSL
? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity }
: false,
},
keys: getKeys(),
};
@@ -137,7 +145,7 @@ const generateKeys = () => {
// Write keys config
try {
fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2));
fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2), { mode: 0o600 });
} catch (err) {
logger.error(`Could not write JWT key pair to config file: ${keysFile}: ${err.message}`);
process.exit(1);
@@ -222,7 +230,7 @@ const isDebugMode = () => !!process.env.DEBUG;
*
* @returns {boolean}
*/
const isCI = () => process.env.CI === 'true' && process.env.DEBUG === 'true';
const isCI = () => process.env.CI === "true" && process.env.DEBUG === "true";
/**
* Returns a public key
@@ -259,4 +267,16 @@ const useLetsencryptServer = () => {
return null;
};
export { isCI, configHas, configGet, isSqlite, isMysql, isPostgres, isDebugMode, getPrivateKey, getPublicKey, useLetsencryptStaging, useLetsencryptServer };
export {
isCI,
configHas,
configGet,
isSqlite,
isMysql,
isPostgres,
isDebugMode,
getPrivateKey,
getPublicKey,
useLetsencryptStaging,
useLetsencryptServer,
};
+9
View File
@@ -22,6 +22,15 @@ const errs = {
this.status = 404;
},
TokenRevokedError: function (message, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.previous = previous;
this.message = message;
this.public = true;
this.status = 401;
},
AuthError: function (message, messageI18n, previous) {
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
+1 -1
View File
@@ -1,4 +1,4 @@
import _ from "lodash";
import _ from "lodash";
export default (default_sort, default_offset, default_limit, max_limit) => {
/**
+2 -2
View File
@@ -1,6 +1,6 @@
export default (req, res, next) => {
if (req.params.user_id === 'me' && res.locals.access) {
req.params.user_id = res.locals.access.token.get('attrs').id;
if (req.params.user_id === "me" && res.locals.access) {
req.params.user_id = res.locals.access.token.get("attrs").id;
} else {
req.params.user_id = Number.parseInt(req.params.user_id, 10);
}
+4 -5
View File
@@ -1,3 +1,4 @@
import net from "node:net";
import Ajv from "ajv/dist/2020.js";
import errs from "../error.js";
@@ -9,6 +10,8 @@ const ajv = new Ajv({
coerceTypes: true,
});
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
/**
* @param {Object} schema
* @param {Object} payload
@@ -24,21 +27,17 @@ const apiValidator = async (schema, payload /*, description*/) => {
throw new errs.ValidationError("Payload is undefined");
}
const validate = ajv.compile(schema);
const valid = validate(payload);
if (valid && !validate.errors) {
return payload;
}
const message = ajv.errorsText(validate.errors);
const err = new errs.ValidationError(message);
err.debug = {validationErrors: validate.errors, payload};
err.debug = { validationErrors: validate.errors, payload };
throw err;
};
+4 -1
View File
@@ -1,4 +1,5 @@
import Ajv from 'ajv/dist/2020.js';
import net from "node:net";
import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import commonDefinitions from "../../schema/common.json" with { type: "json" };
import errs from "../error.js";
@@ -14,6 +15,8 @@ const ajv = new Ajv({
schemas: [commonDefinitions],
});
ajv.addFormat("ipv6", { type: "string", validate: (value) => net.isIPv6(value) });
/**
*
* @param {Object} schema
+111 -11
View File
@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import signale from "signale";
import { isDebugMode } from "./lib/config.js";
@@ -5,17 +7,115 @@ const opts = {
logLevel: "info",
};
const global = new signale.Signale({ scope: "Global ", ...opts });
const migrate = new signale.Signale({ scope: "Migrate ", ...opts });
const express = new signale.Signale({ scope: "Express ", ...opts });
const access = new signale.Signale({ scope: "Access ", ...opts });
const nginx = new signale.Signale({ scope: "Nginx ", ...opts });
const ssl = new signale.Signale({ scope: "SSL ", ...opts });
const certbot = new signale.Signale({ scope: "Certbot ", ...opts });
const importer = new signale.Signale({ scope: "Importer ", ...opts });
const setup = new signale.Signale({ scope: "Setup ", ...opts });
const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts });
const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts });
// Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc).
// Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...)
// are left console-only since they carry no diagnostic value worth persisting.
const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"];
const LOG_FILE = "/data/logs/backend.log";
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
let fileStream = null;
let lastOpenAttempt = 0;
// If the file can't be opened (eg. running outside the standard Docker image, or in
// CI without /data), don't retry on every single log call - but do retry periodically
// so a transient issue (disk full, permissions fixed later) recovers without a restart.
const REOPEN_COOLDOWN_MS = 30 * 1000;
/**
* Lazily opens the backend log file for appending. If the directory isn't writable,
* file logging is silently disabled and console logging continues unaffected.
*
* @returns {import('node:fs').WriteStream|null}
*/
const getFileStream = () => {
if (fileStream) {
return fileStream;
}
const now = Date.now();
if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) {
return null;
}
lastOpenAttempt = now;
try {
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
const stream = fs.createWriteStream(LOG_FILE, { flags: "a" });
stream.on("error", () => {
fileStream = null;
});
fileStream = stream;
} catch (_err) {
fileStream = null;
}
return fileStream;
};
/**
* Formats and appends a single log line to the backend log file.
* Never throws - a failure here must never take down the application.
*
* @param {String} level
* @param {String} scope
* @param {Array} args
*/
const writeToFile = (level, scope, args) => {
const stream = getFileStream();
if (!stream) {
return;
}
const message = args
.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return arg.stack || arg.message;
try {
return JSON.stringify(arg);
} catch (_err) {
return String(arg);
}
})
.join(" ")
.replace(ANSI_PATTERN, "");
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`;
stream.write(line);
};
/**
* Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also
* appended to the backend log file, in addition to its normal console output.
*
* @param {Signale} instance
* @param {String} scope
* @returns {Signale}
*/
const withFileSink = (instance, scope) => {
for (const method of PERSISTED_METHODS) {
const original = instance[method].bind(instance);
instance[method] = (...args) => {
writeToFile(method, scope, args);
return original(...args);
};
}
return instance;
};
const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope);
const global = createLogger("Global ");
const migrate = createLogger("Migrate ");
const express = createLogger("Express ");
const access = createLogger("Access ");
const nginx = createLogger("Nginx ");
const ssl = createLogger("SSL ");
const certbot = createLogger("Certbot ");
const importer = createLogger("Importer ");
const setup = createLogger("Setup ");
const ipRanges = createLogger("IP Ranges");
const remoteVersion = createLogger("Remote Version");
const debug = (logger, ...args) => {
if (isDebugMode()) {
@@ -13,13 +13,14 @@ const migrateName = "settings";
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema.createTable('setting', (table) => {
table.string('id').notNull().primary();
table.string('name', 100).notNull();
table.string('description', 255).notNull();
table.string('value', 255).notNull();
table.json('meta').notNull();
})
return knex.schema
.createTable("setting", (table) => {
table.string("id").notNull().primary();
table.string("name", 100).notNull();
table.string("description", 255).notNull();
table.string("value", 255).notNull();
table.json("meta").notNull();
})
.then(() => {
logger.info(`[${migrateName}] setting Table created`);
});
@@ -17,9 +17,7 @@ const up = (knex) => {
.table("redirection_host", async (table) => {
// change the column default from $scheme to auto
await table.string("forward_scheme").notNull().defaultTo("auto").alter();
await knex('redirection_host')
.where('forward_scheme', '$scheme')
.update({ forward_scheme: 'auto' });
await knex("redirection_host").where("forward_scheme", "$scheme").update({ forward_scheme: "auto" });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -38,9 +36,7 @@ const down = (knex) => {
return knex.schema
.table("redirection_host", async (table) => {
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
await knex('redirection_host')
.where('forward_scheme', 'auto')
.update({ forward_scheme: '$scheme' });
await knex("redirection_host").where("forward_scheme", "auto").update({ forward_scheme: "$scheme" });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -11,15 +11,15 @@ const migrateName = "trust_forwarded_proto";
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema
.alterTable('proxy_host', (table) => {
table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0);
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
return knex.schema
.alterTable("proxy_host", (table) => {
table.tinyint("trust_forwarded_proto").notNullable().defaultTo(0);
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
};
/**
@@ -29,15 +29,15 @@ const up = (knex) => {
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);
logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema
.alterTable('proxy_host', (table) => {
table.dropColumn('trust_forwarded_proto');
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
return knex.schema
.alterTable("proxy_host", (table) => {
table.dropColumn("trust_forwarded_proto");
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
};
export { up, down };
export { up, down };
+4
View File
@@ -31,6 +31,10 @@ class AccessList extends Model {
$parseDatabaseJson(json) {
const thisJson = super.$parseDatabaseJson(json);
// Postgres returns COUNT() as a string
if (typeof thisJson.proxy_host_count === "string") {
thisJson.proxy_host_count = Number.parseInt(thisJson.proxy_host_count, 10);
}
return convertIntFieldsToBool(thisJson, boolFields);
}
+8 -8
View File
@@ -7,23 +7,23 @@ import db from "../db.js";
Model.knex(db());
class Setting extends Model {
$beforeInsert () {
$beforeInsert() {
// Default for meta
if (typeof this.meta === 'undefined') {
if (typeof this.meta === "undefined") {
this.meta = {};
}
}
static get name () {
return 'Setting';
static get name() {
return "Setting";
}
static get tableName () {
return 'setting';
static get tableName() {
return "setting";
}
static get jsonAttributes () {
return ['meta'];
static get jsonAttributes() {
return ["meta"];
}
}
+7 -7
View File
@@ -8,21 +8,21 @@ import now from "./now_helper.js";
Model.knex(db());
class UserPermission extends Model {
$beforeInsert () {
this.created_on = now();
$beforeInsert() {
this.created_on = now();
this.modified_on = now();
}
$beforeUpdate () {
$beforeUpdate() {
this.modified_on = now();
}
static get name () {
return 'UserPermission';
static get name() {
return "UserPermission";
}
static get tableName () {
return 'user_permission';
static get tableName() {
return "user_permission";
}
}
+14 -14
View File
@@ -13,37 +13,37 @@
"regenerate-config": "node scripts/regenerate-config"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^15.3.5",
"@apidevtools/json-schema-ref-parser": "^16.0.2",
"ajv": "^8.20.0",
"archiver": "^8.0.0",
"batchflow": "^0.4.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.10.0",
"body-parser": "^2.2.2",
"better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0",
"chalk": "5.6.2",
"compression": "^1.8.1",
"compression": "^1.8.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"gravatar": "^1.8.2",
"jsonwebtoken": "^9.0.3",
"knex": "3.2.10",
"liquidjs": "10.27.0",
"knex": "3.3.0",
"liquidjs": "10.29.0",
"lodash": "^4.18.1",
"moment": "^2.30.1",
"mysql2": "^3.22.3",
"node-rsa": "^1.1.1",
"moment": "^2.31.0",
"mysql2": "^3.24.4",
"node-rsa": "^2.0.0",
"objection": "3.1.5",
"otplib": "^13.4.0",
"otplib": "^13.5.0",
"path": "^0.12.7",
"pg": "^8.21.0",
"proxy-agent": "^8.0.1",
"pg": "^8.23.0",
"proxy-agent": "^8.0.2",
"signale": "1.4.0",
"sqlite3": "^6.0.1",
"temp-write": "^6.0.1"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@biomejs/biome": "^2.4.15",
"@apidevtools/swagger-parser": "^13.0.0",
"@biomejs/biome": "^2.5.10",
"nodemon": "^3.1.14"
},
"signale": {
+1 -4
View File
@@ -86,10 +86,7 @@ router
},
{
event_id: req.params.event_id,
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
+106
View File
@@ -0,0 +1,106 @@
import express from "express";
import internalLogViewer from "../internal/log-viewer.js";
import jwtdecode from "../lib/express/jwt-decode.js";
import validator from "../lib/validator/index.js";
import { debug, express as logger } from "../logger.js";
const router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true,
});
/**
* /api/logs/sources
*/
router
.route("/sources")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/logs/sources
*
* Lists the log sources available for the log viewer
*/
.get(async (req, res, next) => {
try {
const data = await internalLogViewer.listSources(res.locals.access);
res.status(200).send(data);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
/**
* /api/logs/tail
*/
router
.route("/tail")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/logs/tail
*
* Retrieve the last N lines of a log source
*/
.get(async (req, res, next) => {
try {
const data = await validator(
{
required: ["type"],
additionalProperties: false,
properties: {
type: {
type: "string",
enum: ["system", "letsencrypt", "host"],
},
host_type: {
anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }],
},
host_id: {
anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }],
},
channel: {
anyOf: [{ type: "null" }, { type: "string", enum: ["access", "error"] }],
},
lines: {
anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: 1000 }],
},
level: {
anyOf: [
{ type: "null" },
{ type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] },
],
},
search: {
anyOf: [{ type: "null" }, { type: "string", minLength: 1, maxLength: 200 }],
},
},
},
{
type: req.query.type,
host_type: typeof req.query.host_type === "string" ? req.query.host_type : null,
host_id: typeof req.query.host_id !== "undefined" ? req.query.host_id : null,
channel: typeof req.query.channel === "string" ? req.query.channel : null,
lines: typeof req.query.lines !== "undefined" ? req.query.lines : null,
level: typeof req.query.level === "string" ? req.query.level : null,
search: typeof req.query.search === "string" ? req.query.search : null,
},
);
const result = await internalLogViewer.tail(res.locals.access, data);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;
+2
View File
@@ -6,6 +6,7 @@ import pjson from "../package.json" with { type: "json" };
import { isSetup } from "../setup.js";
import auditLogRoutes from "./audit-log.js";
import ciRoutes from "./ci.js";
import logsRoutes from "./logs.js";
import accessListsRoutes from "./nginx/access_lists.js";
import certificatesHostsRoutes from "./nginx/certificates.js";
import deadHostsRoutes from "./nginx/dead_hosts.js";
@@ -50,6 +51,7 @@ router.use("/schema", schemaRoutes);
router.use("/tokens", tokensRoutes);
router.use("/users", usersRoutes);
router.use("/audit-log", auditLogRoutes);
router.use("/logs", logsRoutes);
router.use("/reports", reportsRoutes);
router.use("/settings", settingsRoutes);
router.use("/version", versionRoutes);
+7 -29
View File
@@ -44,18 +44,11 @@ router
},
},
{
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
query: typeof req.query.query === "string" ? req.query.query : null,
},
);
const rows = await internalCertificate.getAll(
res.locals.access,
data.expand,
data.query,
);
const rows = await internalCertificate.getAll(res.locals.access, data.expand, data.query);
res.status(200).send(rows);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -70,15 +63,9 @@ router
*/
.post(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/nginx/certificates", "post"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/nginx/certificates", "post"), req.body);
req.setTimeout(900000); // 15 minutes timeout
const result = await internalCertificate.create(
res.locals.access,
payload,
);
const result = await internalCertificate.create(res.locals.access, payload);
res.status(201).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -139,16 +126,10 @@ router
*/
.post(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/nginx/certificates/test-http", "post"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/nginx/certificates/test-http", "post"), req.body);
req.setTimeout(60000); // 1 minute timeout
const result = await internalCertificate.testHttpsChallenge(
res.locals.access,
payload,
);
const result = await internalCertificate.testHttpsChallenge(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -224,10 +205,7 @@ router
},
{
certificate_id: req.params.certificate_id,
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
const row = await internalCertificate.get(res.locals.access, {
+4 -2
View File
@@ -194,9 +194,11 @@ router
/**
* POST /api/nginx/dead-hosts/123/disable
*/
.post((req, res, next) => {
.post(async (req, res, next) => {
try {
const result = internalDeadHost.disable(res.locals.access, { id: Number.parseInt(req.params.host_id, 10) });
const result = await internalDeadHost.disable(res.locals.access, {
id: Number.parseInt(req.params.host_id, 10),
});
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
+67
View File
@@ -1,4 +1,5 @@
import express from "express";
import fs from "node:fs";
import internalProxyHost from "../../internal/proxy-host.js";
import jwtdecode from "../../lib/express/jwt-decode.js";
import apiValidator from "../../lib/validator/api.js";
@@ -206,4 +207,70 @@ router
}
});
/**
* Proxy-host logs
*
* /api/nginx/proxy-hosts/123/logs
*/
router
.route("/:host_id/logs")
.options((_, res) => {
res.sendStatus(204);
})
.all(jwtdecode())
/**
* GET /api/nginx/proxy-hosts/123/logs
*
* Retrieve logs for a specific proxy-host
*/
.get(async (req, res, next) => {
try {
const data = await validator(
{
required: ["host_id"],
additionalProperties: false,
properties: {
host_id: {
$ref: "common#/properties/id",
},
type: {
type: "string",
enum: ["access", "error"],
},
},
},
{
host_id: req.params.host_id,
type: req.query.type || "access",
},
);
const hostId = Number.parseInt(data.host_id, 10);
const logType = data.type === "error" ? "error" : "access";
const logFile = `/data/logs/proxy-host-${hostId}_${logType}.log`;
// Check access permission
await res.locals.access.can("proxy_hosts:get", hostId);
let logs = "";
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, { encoding: "utf8" });
const lines = content.split("\n");
// Return last 1000 lines to avoid huge payloads
const maxLines = 1000;
if (lines.length > maxLines) {
logs = lines.slice(-maxLines).join("\n");
} else {
logs = content;
}
}
res.status(200).send({ logs });
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
next(err);
}
});
export default router;
+10 -41
View File
@@ -48,18 +48,11 @@ router
},
},
{
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
query: typeof req.query.query === "string" ? req.query.query : null,
},
);
const users = await internalUser.getAll(
res.locals.access,
data.expand,
data.query,
);
const users = await internalUser.getAll(res.locals.access, data.expand, data.query);
res.status(200).send(users);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -95,10 +88,7 @@ router
}
}
const payload = await apiValidator(
getValidationSchema("/users", "post"),
body,
);
const payload = await apiValidator(getValidationSchema("/users", "post"), body);
const user = await internalUser.create(res.locals.access, payload);
res.status(201).send(user);
} catch (err) {
@@ -169,20 +159,14 @@ router
},
{
user_id: req.params.user_id,
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
const user = await internalUser.get(res.locals.access, {
id: data.user_id,
expand: data.expand,
omit: internalUser.getUserOmisionsByAccess(
res.locals.access,
data.user_id,
),
omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id),
});
res.status(200).send(user);
} catch (err) {
@@ -198,10 +182,7 @@ router
*/
.put(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/users/{userID}", "put"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.update(res.locals.access, payload);
res.status(200).send(result);
@@ -248,10 +229,7 @@ router
*/
.put(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/users/{userID}/auth", "put"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.setPassword(res.locals.access, payload);
res.status(200).send(result);
@@ -281,15 +259,9 @@ router
*/
.put(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/users/{userID}/permissions", "put"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.setPermissions(
res.locals.access,
payload,
);
const result = await internalUser.setPermissions(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -408,10 +380,7 @@ router
*/
.post(async (req, res, next) => {
try {
const { code } = await apiValidator(
getValidationSchema("/users/{userID}/2fa/enable", "post"),
req.body,
);
const { code } = await apiValidator(getValidationSchema("/users/{userID}/2fa/enable", "post"), req.body);
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
res.status(200).send(result);
} catch (err) {
@@ -0,0 +1,69 @@
{
"type": "object",
"description": "Available log sources for the log viewer",
"required": ["system", "letsencrypt", "hosts"],
"additionalProperties": false,
"properties": {
"system": {
"type": "object",
"properties": {
"label": {
"type": "string",
"example": "System"
}
}
},
"letsencrypt": {
"type": "object",
"properties": {
"label": {
"type": "string",
"example": "Let's Encrypt"
}
}
},
"hosts": {
"type": "object",
"required": ["proxy", "redirection", "dead", "stream"],
"additionalProperties": false,
"properties": {
"proxy": {
"$ref": "#/$defs/host-option-list"
},
"redirection": {
"$ref": "#/$defs/host-option-list"
},
"dead": {
"$ref": "#/$defs/host-option-list"
},
"stream": {
"$ref": "#/$defs/host-option-list"
}
},
"example": {
"proxy": [{ "id": 1, "label": "example.com" }],
"redirection": [],
"dead": [],
"stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
}
}
},
"$defs": {
"host-option-list": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "label"],
"properties": {
"id": {
"$ref": "../common.json#/properties/id"
},
"label": {
"type": "string",
"example": "example.com"
}
}
}
}
}
}
@@ -0,0 +1,30 @@
{
"type": "object",
"description": "The last N lines of a log source",
"required": ["lines", "size", "truncated", "exists"],
"additionalProperties": false,
"properties": {
"lines": {
"type": "array",
"items": {
"type": "string"
},
"example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."]
},
"size": {
"type": "integer",
"description": "Size in bytes of the underlying log file",
"example": 4096
},
"truncated": {
"type": "boolean",
"description": "True when the file is larger than the maximum amount of data scanned per request",
"example": false
},
"exists": {
"type": "boolean",
"description": "False when the underlying log file does not exist yet",
"example": true
}
}
}
@@ -124,6 +124,9 @@
},
"advanced_config": {
"type": "string"
},
"access_list_id": {
"$ref": "../common.json#/properties/access_list_id"
}
}
},
@@ -132,7 +135,8 @@
"path": "/app",
"forward_scheme": "http",
"forward_host": "example.com",
"forward_port": 80
"forward_port": 80,
"access_list_id": 0
}
]
},
+1 -1
View File
@@ -44,7 +44,7 @@
},
{
"type": "string",
"format": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
"pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$"
},
{
"type": "string",
@@ -0,0 +1,36 @@
{
"operationId": "getLogSources",
"summary": "Get available log sources",
"tags": ["logs"],
"security": [
{
"bearerAuth": ["admin"]
}
],
"responses": {
"200": {
"description": "200 response",
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"system": { "label": "System" },
"letsencrypt": { "label": "Let's Encrypt" },
"hosts": {
"proxy": [{ "id": 1, "label": "example.com" }],
"redirection": [],
"dead": [],
"stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }]
}
}
}
},
"schema": {
"$ref": "../../../components/log-sources-object.json"
}
}
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
{
"operationId": "getLogTail",
"summary": "Get the last N lines of a log source",
"tags": ["logs"],
"security": [
{
"bearerAuth": ["admin"]
}
],
"parameters": [
{
"in": "query",
"name": "type",
"required": true,
"description": "Which log source to read",
"schema": {
"type": "string",
"enum": ["system", "letsencrypt", "host"]
}
},
{
"in": "query",
"name": "host_type",
"description": "Required when type=host",
"schema": {
"type": "string",
"enum": ["proxy", "redirection", "dead", "stream"]
}
},
{
"in": "query",
"name": "host_id",
"description": "Required when type=host",
"schema": {
"type": "integer",
"minimum": 1
}
},
{
"in": "query",
"name": "channel",
"description": "Required when type=host - which log file to read (not to be confused with host_type=stream)",
"schema": {
"type": "string",
"enum": ["access", "error"]
}
},
{
"in": "query",
"name": "lines",
"description": "Number of lines to return from the end of the file",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 200
}
},
{
"in": "query",
"name": "level",
"description": "Only applicable to type=system",
"schema": {
"type": "string",
"enum": ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]
}
},
{
"in": "query",
"name": "search",
"description": "Case-insensitive plain-text search",
"schema": {
"type": "string",
"maxLength": 200
}
}
],
"responses": {
"200": {
"description": "200 response",
"content": {
"application/json": {
"examples": {
"default": {
"value": {
"lines": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."],
"size": 4096,
"truncated": false,
"exists": true
}
}
},
"schema": {
"$ref": "../../../components/log-tail-object.json"
}
}
}
}
}
}
+14
View File
@@ -24,6 +24,10 @@
"name": "audit-log",
"description": "Endpoints related to Audit Logs"
},
{
"name": "logs",
"description": "Endpoints for viewing system, Let's Encrypt and per-host logs"
},
{
"name": "access-lists",
"description": "Endpoints related to Access Lists"
@@ -81,6 +85,16 @@
"$ref": "./paths/audit-log/id/get.json"
}
},
"/logs/sources": {
"get": {
"$ref": "./paths/logs/sources/get.json"
}
},
"/logs/tail": {
"get": {
"$ref": "./paths/logs/tail/get.json"
}
},
"/nginx/access-lists": {
"get": {
"$ref": "./paths/nginx/access-lists/get.json"
+6 -6
View File
@@ -46,7 +46,7 @@ const logIt = (msg, type = "info") => logger[type](
// Let's do it.
const processItems = async (model, type) => {
const processItems = async (model, type, hostType) => {
const rows = await model
.query()
.where("is_deleted", 0)
@@ -60,17 +60,17 @@ const processItems = async (model, type) => {
for (const row of rows) {
if (!DRY_RUN) {
logIt(`[${type}] Regenerating config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
await internalNginx.configure(proxyHostModel, "proxy_host", row);
await internalNginx.configure(model, hostType, row);
} else {
logIt(`[${type}] Skipping generation of config #${row.id}: ${row.domain_names ? row.domain_names.join(", ") : 'port ' + row.incoming_port}`);
}
}
};
await processItems(proxyHostModel, "Proxy Host");
await processItems(redirectionHostModel, "Redirection Host");
await processItems(deadHostModel, "404 Host");
await processItems(streamModel, "Stream");
await processItems(proxyHostModel, "Proxy Host", "proxy_host");
await processItems(redirectionHostModel, "Redirection Host", "redirection_host");
await processItems(deadHostModel, "404 Host", "dead_host");
await processItems(streamModel, "Stream", "stream");
logIt("Completed", "success");
process.exit(0);
+29 -43
View File
@@ -6,11 +6,12 @@ import certificateModel from "./models/certificate.js";
import settingModel from "./models/setting.js";
import userModel from "./models/user.js";
import userPermissionModel from "./models/user_permission.js";
import fs from "node:fs/promises";
export const isSetup = async () => {
const row = await userModel.query().select("id").where("is_deleted", 0).first();
return row?.id > 0;
}
};
/**
* Creates a default admin users if one doesn't already exist in the database
@@ -44,18 +45,14 @@ const setupDefaultUser = async () => {
roles: ["admin"],
};
const user = await userModel
.query()
.insertAndFetch(data);
const user = await userModel.query().insertAndFetch(data);
await authModel
.query()
.insert({
user_id: user.id,
type: "password",
secret: initialAdminPassword,
meta: {},
});
await authModel.query().insert({
user_id: user.id,
type: "password",
secret: initialAdminPassword,
meta: {},
});
await userPermissionModel.query().insert({
user_id: user.id,
@@ -77,22 +74,16 @@ const setupDefaultUser = async () => {
* @returns {Promise}
*/
const setupDefaultSettings = async () => {
const row = await settingModel
.query()
.select("id")
.where({ id: "default-site" })
.first();
const row = await settingModel.query().select("id").where({ id: "default-site" }).first();
if (!row?.id) {
await settingModel
.query()
.insert({
id: "default-site",
name: "Default Site",
description: "What to show when Nginx is hit with an unknown Host",
value: "congratulations",
meta: {},
});
await settingModel.query().insert({
id: "default-site",
name: "Default Site",
description: "What to show when Nginx is hit with an unknown Host",
value: "congratulations",
meta: {},
});
logger.info("Default settings added");
}
};
@@ -103,14 +94,10 @@ const setupDefaultSettings = async () => {
* @returns {Promise}
*/
const setupCertbotPlugins = async () => {
const certificates = await certificateModel
.query()
.where("is_deleted", 0)
.andWhere("provider", "letsencrypt");
const certificates = await certificateModel.query().where("is_deleted", 0).andWhere("provider", "letsencrypt");
if (certificates?.length) {
const plugins = [];
const promises = [];
certificates.map((certificate) => {
if (certificate.meta && certificate.meta.dns_challenge === true) {
@@ -118,24 +105,23 @@ const setupCertbotPlugins = async () => {
plugins.push(certificate.meta.dns_provider);
}
// Make sure credentials file exists
const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
// Escape single quotes and backslashes
if (typeof certificate.meta.dns_provider_credentials === "string") {
const escapedCredentials = certificate.meta.dns_provider_credentials
.replaceAll("'", "\\'")
.replaceAll("\\", "\\\\");
const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
promises.push(utils.exec(credentials_cmd));
}
// Deliberately does NOT write the DNS credentials file here any more.
//
// It used to, so that a later `certbot renew` would find the path recorded in its
// renewal config. The effect was that every backend restart rewrote a plaintext
// DNS provider API token for every DNS-01 certificate, and left it there.
//
// internalCertificate now writes that file immediately before it runs certbot and
// removes it again afterwards, so there is exactly one writer and the credential
// is on disk only for the length of a certbot run. Recreating the files at boot
// would put every one of them straight back.
}
return true;
});
await installPlugins(plugins);
if (promises.length) {
await Promise.all(promises);
if (plugins.length) {
logger.info(`Added Certbot plugins ${plugins.join(", ")}`);
}
}
+4 -4
View File
@@ -5,8 +5,8 @@
{% if enabled %}
{% if tcp_forwarding == 1 or tcp_forwarding == true -%}
server {
listen {{ incoming_port }} {%- if certificate %} ssl {%- endif %};
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %};
listen {{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} reuseport {%- if certificate %} ssl {%- endif %};
{%- include "_certificates_stream.conf" %}
@@ -23,8 +23,8 @@ server {
{% if udp_forwarding == 1 or udp_forwarding == true -%}
server {
listen {{ incoming_port }} udp;
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp;
listen {{ incoming_port }} udp reuseport;
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp reuseport;
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
+417 -371
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ RUN curl -sL 'https://taskfile.dev/install.sh' | sh
COPY rootfs /
COPY scripts/install-s6 /tmp/install-s6
RUN rm -f /etc/nginx/conf.d/production.conf \
RUN rm -f /etc/nginx/conf.d/production.conf.template \
&& chmod 644 /etc/logrotate.d/nginx-proxy-manager \
&& /tmp/install-s6 "${TARGETPLATFORM}" \
&& rm -f /tmp/install-s6 \
+1
View File
@@ -28,6 +28,7 @@ acl SSL_ports port 443
acl Safe_ports port 80 # http
acl Safe_ports port 81
acl Safe_ports port 443 # https
acl Safe_ports port 8000 # for CI testing with custom admin port
#
# Recommended minimum Access Permission configuration:
+4
View File
@@ -3,6 +3,7 @@ services:
cypress:
environment:
CYPRESS_stack: "postgres"
NPM_ADMIN_PORT: 8000
fullstack:
environment:
@@ -11,11 +12,14 @@ services:
DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass"
DB_POSTGRES_NAME: "npm"
NPM_ADMIN_PORT: 8000
depends_on:
- db-postgres
- authentik
- authentik-worker
- authentik-ldap
expose:
- "8000/tcp"
db-postgres:
image: postgres:17
+11
View File
@@ -38,6 +38,16 @@ services:
- website2.example.com
- website3.example.com
examplesite:
image: "${IMAGE}-examplesite:ci-${BUILD_NUMBER}"
build:
context: ../test/docker
dockerfile: Dockerfile.website
expose:
- "80/tcp"
networks:
- fulltest
stepca:
image: nginxproxymanager/testca
volumes:
@@ -105,6 +115,7 @@ services:
environment:
HTTP_PROXY: "squid:3128"
HTTPS_PROXY: "squid:3128"
NPM_ADMIN_PORT: 81
volumes:
- "cypress_logs:/test/results"
- "./dev/resolv.conf:/etc/resolv.conf:ro"
@@ -25,3 +25,13 @@
kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true
endscript
}
/data/logs/backend.log {
su npm npm
size 10M
rotate 5
missingok
notifempty
compress
copytruncate
}
@@ -1,7 +1,7 @@
# Admin Interface
server {
listen 81 default;
listen [::]:81 default;
listen {{NPM_ADMIN_PORT}} default;
listen [::]:{{NPM_ADMIN_PORT}} default;
server_name nginxproxymanager;
root /app/frontend;
@@ -17,6 +17,7 @@ fi
. /etc/s6-overlay/s6-rc.d/prepare/20-paths.sh
. /etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh
. /etc/s6-overlay/s6-rc.d/prepare/40-dynamic.sh
. /etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
. /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh
. /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh
@@ -46,6 +46,12 @@ for loc in "${locations[@]}"; do
chownit "$loc"
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
fi
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
log_info 'Skipping ownership change of certbot directories'
else
@@ -0,0 +1,30 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# This command reads the `NPM_ADMIN_PORT` env var and will fall
# back to 81 if this is not set or is not a number.
set -e
log_info 'Admin Port ...'
NPM_ADMIN_PORT="${NPM_ADMIN_PORT:-81}"
# ensure admin port is a number
if ! [[ "$NPM_ADMIN_PORT" =~ ^[0-9]+$ ]]; then
echo "WARNING: NPM_ADMIN_PORT must be a number. Defaulting to 81" >&2
NPM_ADMIN_PORT=81
fi
PRODFILE="/etc/nginx/conf.d/production.conf"
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g"
if is_mounted "$PRODFILE"; then
echo "WARNING: skipping ${PRODFILE} — mounted file" >&2
elif [ -f "$PRODFILE.template" ]; then
if sed -E "$SED_REGEX" "$PRODFILE.template" > "$PRODFILE" && [ -s "$PRODFILE" ]; then
# success
log_info "Generated ${PRODFILE} from template"
else
log_fatal "Failed to generate ${PRODFILE} from template"
fi
fi
@@ -26,7 +26,10 @@ process_folder () {
do
echo "- ${FILE}"
TMPFILE="${FILE}.tmp"
if sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
if is_mounted "${FILE}"; then
echo "WARNING: skipping ${FILE} — mounted file" >&2
elif sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
mv "$TMPFILE" "$FILE"
else
echo "WARNING: skipping ${FILE} — sed produced empty output" >&2
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
OK=$(curl --silent http://127.0.0.1:81/api/ | jq --raw-output '.status')
OK=$(curl --silent "http://127.0.0.1:${NPM_ADMIN_PORT:-81}/api/" | jq --raw-output '.status')
if [ "$OK" == "OK" ]; then
echo "OK"
+4
View File
@@ -70,3 +70,7 @@ is_true () {
echo '0'
fi
}
is_mounted() {
awk -v p="$1" '$5 == p { found=1 } END { exit !found }' /proc/self/mountinfo
}
+1 -1
View File
@@ -8,7 +8,7 @@ BLUE='\E[1;34m'
GREEN='\E[1;32m'
RESET='\E[0m'
S6_OVERLAY_VERSION=3.2.1.0
S6_OVERLAY_VERSION=3.2.3.0
TARGETPLATFORM=${1:-linux/amd64}
# Determine the correct binary file for the architecture given
+18
View File
@@ -248,3 +248,21 @@ On startup, we generate a resolvers directive for Nginx unless this is defined:
In this configuration, all DNS queries performed by Nginx will fall to the `/etc/hosts` file
and then the `/etc/resolv.conf`.
## Changing the Admin UI port from 81 to something else
First, add an env var to your docker compose file:
```yml
environment:
NPM_ADMIN_PORT: '8000'
```
And you'll probably want to expose that port as well
```yml
ports:
- '8000:8000'
```
Then you'll be able to access admin UI at `http://localhost:8000`
+8 -8
View File
@@ -952,10 +952,10 @@ mitt@^3.0.1:
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
oniguruma-to-es@^3.1.0:
version "3.1.1"
@@ -977,11 +977,11 @@ picocolors@^1.1.1:
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
postcss@^8.4.43, postcss@^8.5.6:
version "8.5.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
version "8.5.25"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb"
integrity sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==
dependencies:
nanoid "^3.3.11"
nanoid "^3.3.16"
picocolors "^1.1.1"
source-map-js "^1.2.1"
+1 -1
View File
@@ -1,7 +1,7 @@
src/locale/lang
# Logs
logs
/logs/
*.log
npm-debug.log*
yarn-debug.log*
+73 -90
View File
@@ -1,92 +1,75 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"!**/dist/**/*"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
[
"npm:*",
"npm:*/**"
],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
[
"/src/*",
"/src/**"
],
[
"/**"
],
[
"#*",
"#*/**"
],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
["npm:*", "npm:*/**"],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
["/src/*", "/src/**"],
["/**"],
["#*", "#*/**"],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
}
Executable → Regular
+3
View File
@@ -29,6 +29,9 @@ const allLocales = [
["tr", "tr-TR"],
["hu", "hu-HU"],
["no", "no-NO"],
["uk", "uk-UA"],
["az", "az-AZ"],
["fa", "fa-IR"],
];
const ignoreUnused = [/^.*$/];
+31 -30
View File
@@ -16,51 +16,52 @@
"test": "vitest"
},
"dependencies": {
"@tabler/core": "^1.4.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-table": "^8.21.3",
"@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",
"classnames": "^2.5.1",
"country-flag-icons": "^1.6.17",
"date-fns": "^4.3.0",
"country-flag-icons": "^1.6.20",
"date-fns": "^4.4.0",
"ez-modal-react": "^1.0.5",
"formik": "^2.4.9",
"generate-password-browser": "^1.1.0",
"humps": "^2.0.1",
"query-string": "^9.3.1",
"react": "^19.2.6",
"query-string": "^9.5.1",
"react": "^19.3.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.6",
"react-intl": "^10.1.9",
"react-dom": "^19.3.0",
"react-intl": "^10.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.15.1",
"react-qr-code": "^2.2.0",
"react-router-dom": "^7.18.4",
"react-select": "^5.10.2",
"react-toastify": "^11.1.0",
"rooks": "^9.8.0"
"rooks": "^9.9.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@formatjs/cli": "^6.16.3",
"@tanstack/react-query-devtools": "^5.100.14",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@biomejs/biome": "^2.5.10",
"@formatjs/cli": "^6.16.30",
"@tanstack/react-query-devtools": "^5.103.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/humps": "^2.0.6",
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.6.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-table": "^7.7.20",
"@vitejs/plugin-react": "^6.0.2",
"happy-dom": "^20.9.0",
"postcss": "^8.5.15",
"@vitejs/plugin-react": "^6.1.1",
"happy-dom": "^20.14.5",
"postcss": "^8.5.28",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.100.0",
"tmp": "^0.2.6",
"typescript": "6.0.3",
"vite": "^8.0.14",
"vite-plugin-checker": "^0.14.1",
"vitest": "^4.1.7"
"sass": "^1.104.1",
"tmp": "^0.2.7",
"typescript": "7.0.2",
"vite": "^8.3.0",
"vite-plugin-checker": "^0.14.5",
"vitest": "^4.1.11"
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ import EasyModal from "ez-modal-react";
import { RawIntlProvider } from "react-intl";
import { ToastContainer } from "react-toastify";
import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
import { intl } from "src/locale";
import { getLocale, intl, isRTLLocale } from "src/locale";
import Router from "src/Router.tsx";
// Create a client
@@ -26,7 +26,7 @@ function App() {
hideProgressBar={true}
newestOnTop={true}
closeOnClick={true}
rtl={false}
rtl={isRTLLocale(getLocale())}
closeButton={false}
/>
</AuthProvider>
+79
View File
@@ -0,0 +1,79 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import Router from "src/Router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { authState } = vi.hoisted(() => ({ authState: { authenticated: true } }));
vi.mock("src/context", () => ({ useAuthState: () => authState }));
vi.mock("src/hooks", () => ({
useHealth: () => ({ data: { status: "OK", setup: true }, isLoading: false, isError: false }),
}));
vi.mock("src/components", () => ({
Page: ({ children }: { children: ReactNode }) => children,
SiteContainer: ({ children }: { children: ReactNode }) => children,
SiteHeader: () => null,
SiteMenu: () => null,
SiteFooter: () => null,
LoadingPage: () => <div>Loading</div>,
Unhealthy: () => <div>Unhealthy</div>,
ErrorNotFound: () => <h1>Not found</h1>,
}));
vi.mock("src/pages/Dashboard", () => ({ default: () => <h1>Dashboard</h1> }));
vi.mock("src/pages/Login", () => ({ default: () => <h1>Login</h1> }));
vi.mock("src/pages/Nginx/ProxyHosts", () => ({ default: () => <h1>Proxy hosts</h1> }));
describe("Router", () => {
beforeEach(() => {
authState.authenticated = true;
window.history.replaceState(null, "", "/");
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it.each(["/login", "/login/", "/login?next=/users#form"])(
"redirects an authenticated visit to %s to the dashboard",
async (path) => {
window.history.replaceState(null, "", path);
const replaceState = vi.spyOn(window.history, "replaceState");
render(<Router />);
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible();
expect(window.location.pathname).toBe("/");
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("");
expect(replaceState).toHaveBeenCalledWith(expect.anything(), "", "/");
},
);
it("shows the login form when signed out and redirects after sign-in", async () => {
authState.authenticated = false;
window.history.replaceState(null, "", "/login");
const { rerender } = render(<Router />);
expect(await screen.findByRole("heading", { name: "Login" })).toBeVisible();
expect(window.location.pathname).toBe("/login");
authState.authenticated = true;
rerender(<Router />);
expect(await screen.findByRole("heading", { name: "Dashboard" })).toBeVisible();
await waitFor(() => expect(window.location.pathname).toBe("/"));
});
it.each([
["/", "Dashboard"],
["/nginx/proxy", "Proxy hosts"],
["/unknown", "Not found"],
])("preserves the existing route for %s", async (path, heading) => {
window.history.replaceState(null, "", path);
render(<Router />);
expect(await screen.findByRole("heading", { name: heading })).toBeVisible();
expect(window.location.pathname).toBe(path);
});
});
+4 -1
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import {
ErrorNotFound,
LoadingPage,
@@ -20,6 +20,7 @@ const Settings = lazy(() => import("src/pages/Settings"));
const Certificates = lazy(() => import("src/pages/Certificates"));
const Access = lazy(() => import("src/pages/Access"));
const AuditLog = lazy(() => import("src/pages/AuditLog"));
const Logs = lazy(() => import("src/pages/Logs"));
const Users = lazy(() => import("src/pages/Users"));
const ProxyHosts = lazy(() => import("src/pages/Nginx/ProxyHosts"));
const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts"));
@@ -61,9 +62,11 @@ function Router() {
<Suspense fallback={<LoadingPage noLogo />}>
<Routes>
<Route path="*" element={<ErrorNotFound />} />
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="/certificates" element={<Certificates />} />
<Route path="/access" element={<Access />} />
<Route path="/audit-log" element={<AuditLog />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
<Route path="/users" element={<Users />} />
<Route path="/nginx/proxy" element={<ProxyHosts />} />
@@ -0,0 +1,8 @@
import * as api from "./base";
import type { LogSources } from "./models";
export async function getLogSources(): Promise<LogSources> {
return await api.get({
url: "/logs/sources",
});
}
+19
View File
@@ -0,0 +1,19 @@
import * as api from "./base";
import type { LogChannel, LogHostType, LogSourceType, LogTail } from "./models";
export interface GetLogTailParams {
type: LogSourceType;
hostType?: LogHostType;
hostId?: number;
channel?: LogChannel;
lines?: number;
level?: string;
search?: string;
}
export async function getLogTail(params: GetLogTailParams): Promise<LogTail> {
return await api.get({
url: "/logs/tail",
params: { ...params },
});
}
@@ -0,0 +1,8 @@
import * as api from "./base";
export async function getProxyHostLogs(id: number, type: "access" | "error" = "access"): Promise<{ logs: string }> {
return await api.get({
url: `/nginx/proxy-hosts/${id}/logs`,
params: { type },
});
}
+3
View File
@@ -26,7 +26,10 @@ export * from "./getDeadHost";
export * from "./getDeadHosts";
export * from "./getHealth";
export * from "./getHostsReport";
export * from "./getLogSources";
export * from "./getLogTail";
export * from "./getProxyHost";
export * from "./getProxyHostLogs";
export * from "./getProxyHosts";
export * from "./getRedirectionHost";
export * from "./getRedirectionHosts";
+26
View File
@@ -44,6 +44,30 @@ export interface AuditLog {
user?: User;
}
export type LogSourceType = "system" | "letsencrypt" | "host";
export type LogHostType = "proxy" | "redirection" | "dead" | "stream";
// Which of the two files nginx writes per host - named "channel", not "stream",
// to avoid confusion with the "stream" LogHostType (TCP/UDP stream hosts).
export type LogChannel = "access" | "error";
export interface LogHostOption {
id: number;
label: string;
}
export interface LogSources {
system: { label: string };
letsencrypt: { label: string };
hosts: Record<LogHostType, LogHostOption[]>;
}
export interface LogTail {
lines: string[];
size: number;
truncated: boolean;
exists: boolean;
}
export interface AccessList {
id?: number;
createdOn?: string;
@@ -103,6 +127,8 @@ export interface ProxyLocation {
forwardScheme: string;
forwardHost: string;
forwardPort: number;
accessListId?: number;
accessList?: AccessList;
}
export interface ProxyHost {
+6 -5
View File
@@ -1,12 +1,13 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import cn from "classnames";
import type { ReactNode } from "react";
import { Button, HasPermission } from "src/components";
import type { Features } from "src/components/Table/features";
import { T } from "src/locale";
import { type ADMIN, MANAGE, type Permission, type Section } from "src/modules/Permissions";
interface Props {
tableInstance: ReactTable<any>;
interface Props<TData extends RowData> {
tableInstance: ReactTable<Features, TData>;
onNew?: () => void;
isFiltered?: boolean;
object: string;
@@ -16,7 +17,7 @@ interface Props {
permissionSection?: Section | typeof ADMIN;
permission?: Permission;
}
function EmptyData({
function EmptyData<TData extends RowData>({
tableInstance,
onNew,
isFiltered,
@@ -26,7 +27,7 @@ function EmptyData({
customAddBtn,
permissionSection,
permission,
}: Props) {
}: Props<TData>) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
@@ -65,8 +65,12 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value={client.directive}
onChange={(e) => handleChange(idx, "directive", e.target.value)}
>
<option value="allow"><T id="action.allow" /></option>
<option value="deny"><T id="action.deny" /></option>
<option value="allow">
<T id="action.allow" />
</option>
<option value="deny">
<T id="action.deny" />
</option>
</select>
</span>
<input
@@ -81,16 +85,13 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
</div>
</div>
<div className="col-1">
<a
role="button"
<button
type="button"
className="btn btn-ghost btn-danger p-0"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
onClick={() => handleRemove(idx)}
>
<IconX size={16} />
</a>
</button>
</div>
</div>
))}
@@ -112,7 +113,9 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value="deny"
disabled
>
<option value="deny"><T id="action.deny" /></option>
<option value="deny">
<T id="action.deny" />
</option>
</select>
</span>
<input
+30 -9
View File
@@ -1,4 +1,4 @@
import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
import { IconArrowBackUp, IconLock, IconLockOpen2 } from "@tabler/icons-react";
import { Field, useFormikContext } from "formik";
import type { ReactNode } from "react";
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
@@ -31,14 +31,26 @@ interface Props {
id?: string;
name?: string;
label?: string;
onFormChange?: (value: number) => void;
// When set, the 0 option inherits the host's access list instead of being public
inheritHost?: boolean;
}
export function AccessField({ name = "accessListId", label = "access-list", id = "accessListId" }: Props) {
export function AccessField({
name = "accessListId",
label = "access-list",
id = "accessListId",
onFormChange,
inheritHost = false,
}: Props) {
const { locale } = useLocaleState();
const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
const { setFieldValue } = useFormikContext();
const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {
setFieldValue(name, newValue?.value);
if (onFormChange) {
onFormChange(newValue?.value ?? 0);
}
};
const options: AccessOption[] =
@@ -56,13 +68,22 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
icon: <IconLock size={14} className="text-lime" />,
})) || [];
// Public option
options?.unshift({
value: 0,
label: intl.formatMessage({ id: "access-list.public" }),
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
icon: <IconLockOpen2 size={14} className="text-red" />,
});
// Public or inherit option
options?.unshift(
inheritHost
? {
value: 0,
label: intl.formatMessage({ id: "access-list.inherit" }),
subLabel: intl.formatMessage({ id: "access-list.inherit.subtitle" }),
icon: <IconArrowBackUp size={14} className="text-secondary" />,
}
: {
value: 0,
label: intl.formatMessage({ id: "access-list.public" }),
subLabel: intl.formatMessage({ id: "access-list.public.subtitle" }),
icon: <IconLockOpen2 size={14} className="text-red" />,
},
);
return (
<Field name={name}>
@@ -82,16 +82,13 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
/>
</div>
<div className="col-1">
<a
role="button"
<button
type="button"
className="btn btn-ghost btn-danger p-0"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
onClick={() => handleRemove(idx)}
>
<IconX size={16} />
</a>
</button>
</div>
</div>
))}
@@ -1,3 +1,51 @@
/* card-active points --tblr-card-border-color at --tblr-primary, which both the
card outline and the card header's bottom border are drawn from. Tabler's
stylesheet is loaded after this one, so the override needs !important. */
.locationCard {
border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
--tblr-card-border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
}
.filter {
max-width: 20rem;
}
/* The header is a plain toggle rather than a button-styled control, so that a
list of collapsed locations reads as rows instead of a stack of buttons. */
.toggle {
display: flex;
flex: 1 1 auto;
align-self: stretch;
align-items: center;
min-width: 0;
padding: 0;
color: inherit;
text-align: left;
background: transparent;
border: 0;
}
.toggle:focus-visible {
outline: 2px solid var(--tblr-primary);
outline-offset: -2px;
}
/* Keeps the marker on one line next to the delete button, and lets it drop out
of the way before the path does when the row runs out of room. */
.marker {
display: flex;
flex: 0 1 auto;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.path {
font-weight: 500;
white-space: nowrap;
}
.summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+291 -131
View File
@@ -1,20 +1,44 @@
import { IconSettings } from "@tabler/icons-react";
import {
IconChevronDown,
IconChevronRight,
IconPlus,
IconSearch,
IconSettings,
IconTrash,
IconX,
} from "@tabler/icons-react";
import CodeEditor from "@uiw/react-textarea-code-editor";
import cn from "classnames";
import { useFormikContext } from "formik";
import { useState } from "react";
import { useRef, useState } from "react";
import type { ProxyLocation } from "src/api/backend";
import { intl, T } from "src/locale";
import { AccessField } from "./AccessField";
import styles from "./LocationsFields.module.css";
// Below this many locations the list is short enough to scan by eye, and the
// filter would only take up space.
const FILTER_THRESHOLD = 5;
// Locations are identified by a client-side id rather than their array index,
// so that expanded/advanced state stays with the right row when one is removed.
interface Row {
id: number;
value: ProxyLocation;
}
interface Props {
initialValues: ProxyLocation[];
name?: string;
}
export function LocationsFields({ initialValues, name = "locations" }: Props) {
const [values, setValues] = useState<ProxyLocation[]>(initialValues || []);
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
const { setFieldValue } = useFormikContext();
const [expanded, setExpanded] = useState<number[]>([]);
const [advVisible, setAdvVisible] = useState<number[]>([]);
const [filter, setFilter] = useState("");
const nextId = useRef(rows.length);
const scrollToId = useRef<number | null>(null);
const blankItem: ProxyLocation = {
path: "",
@@ -22,34 +46,71 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
forwardScheme: "http",
forwardHost: "",
forwardPort: 80,
accessListId: 0,
};
const toggleAdvVisible = (idx: number) => {
setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
const toggleExpanded = (id: number) => {
setExpanded(expanded.includes(id) ? expanded.filter((i) => i !== id) : [...expanded, id]);
};
const toggleAdvVisible = (id: number) => {
setAdvVisible(advVisible.includes(id) ? advVisible.filter((i) => i !== id) : [...advVisible, id]);
};
const handleAdd = () => {
setValues([...values, blankItem]);
const id = nextId.current++;
setRows([...rows, { id, value: blankItem }]);
// A new location starts empty, so open it and make sure an active filter
// doesn't hide the row that was just added.
setExpanded([...expanded, id]);
setFilter("");
scrollToId.current = id;
};
const handleRemove = (idx: number) => {
const newValues = values.filter((_: ProxyLocation, i: number) => i !== idx);
setValues(newValues);
setFormField(newValues);
const handleRemove = (id: number) => {
const newRows = rows.filter((r: Row) => r.id !== id);
setRows(newRows);
setExpanded(expanded.filter((i) => i !== id));
setAdvVisible(advVisible.filter((i) => i !== id));
setFormField(newRows);
};
const handleChange = (idx: number, field: string, fieldValue: string) => {
const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
setValues(newValues);
setFormField(newValues);
const handleChange = (id: number, field: string, fieldValue: string) => {
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, [field]: fieldValue } } : r));
setRows(newRows);
setFormField(newRows);
};
const setFormField = (newValues: ProxyLocation[]) => {
const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
const handleAccessListChange = (id: number, accessListId: number) => {
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, accessListId } } : r));
setRows(newRows);
setFormField(newRows);
};
const setFormField = (newRows: Row[]) => {
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
setFieldValue(name, filtered);
};
if (values.length === 0) {
const forwardSummary = (item: ProxyLocation) => {
if (!item.forwardHost) {
return "";
}
return `${item.forwardScheme}://${item.forwardHost}${item.forwardPort ? `:${item.forwardPort}` : ""}`;
};
// Matches the path as well as the destination, so a location can be found by
// the host or port it forwards to and not just by its path.
const matchesFilter = (item: ProxyLocation, query: string) =>
[item.path, item.forwardScheme, item.forwardHost, item.forwardPort, forwardSummary(item)]
.join(" ")
.toLowerCase()
.includes(query);
const query = filter.trim().toLowerCase();
const visibleRows = query ? rows.filter((r: Row) => matchesFilter(r.value, query)) : rows;
if (rows.length === 0) {
return (
<div className="text-center">
<button type="button" className="btn my-3" onClick={handleAdd}>
@@ -61,125 +122,224 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
return (
<>
{values.map((item: ProxyLocation, idx: number) => (
<div key={idx} className={cn("card", "card-active", "mb-3", styles.locationCard)}>
<div className="card-body">
<div className="row">
<div className="col-md-10">
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
type="text"
className="form-control"
placeholder="/path"
autoComplete="off"
value={item.path}
onChange={(e) => handleChange(idx, "path", e.target.value)}
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
onClick={() => toggleAdvVisible(idx)}
>
<IconSettings size={20} />
</button>
</div>
</div>
<div className="row">
<div className="col-md-3">
<div className="mb-3">
<label className="form-label" htmlFor="forwardScheme">
<T id="host.forward-scheme" />
</label>
<select
id="forwardScheme"
className="form-control"
value={item.forwardScheme}
onChange={(e) => handleChange(idx, "forwardScheme", e.target.value)}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label" htmlFor="forwardHost">
<T id="proxy-host.forward-host" />
</label>
<input
id="forwardHost"
type="text"
className="form-control"
required
placeholder="eg: 10.0.0.1/path/"
value={item.forwardHost}
onChange={(e) => handleChange(idx, "forwardHost", e.target.value)}
/>
</div>
</div>
<div className="col-md-3">
<div className="mb-3">
<label className="form-label" htmlFor="forwardPort">
<T id="host.forward-port" />
</label>
<input
id="forwardPort"
type="number"
min={1}
max={65535}
className="form-control"
required
placeholder="eg: 8081"
value={item.forwardPort}
onChange={(e) => handleChange(idx, "forwardPort", e.target.value)}
/>
</div>
</div>
</div>
{advVisible.includes(idx) && (
<div className="">
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={170}
indentWidth={2}
value={item.advancedConfig}
onChange={(e) => handleChange(idx, "advancedConfig", e.target.value)}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "170px",
}}
/>
</div>
)}
<div className="mt-1">
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
<div className="d-flex align-items-center mb-3">
{rows.length >= FILTER_THRESHOLD && (
<div className={cn("input-group", styles.filter)}>
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
autoComplete="off"
placeholder={intl.formatMessage({ id: "location.filter" })}
aria-label={intl.formatMessage({ id: "location.filter" })}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filter ? (
<button
type="button"
className="btn btn-icon"
title={intl.formatMessage({ id: "action.clear" })}
aria-label={intl.formatMessage({ id: "action.clear" })}
onClick={() => setFilter("")}
>
<T id="action.delete" />
</a>
</div>
<IconX size={16} />
</button>
) : null}
</div>
</div>
))}
<div>
<button type="button" className="btn btn-sm" onClick={handleAdd}>
)}
<button type="button" className="btn ms-auto" onClick={handleAdd}>
<IconPlus size={16} className="me-1" />
<T id="action.add-location" />
</button>
</div>
{visibleRows.length === 0 ? (
<div className="text-secondary text-center my-3">
<T id="empty-search" />
</div>
) : (
visibleRows.map((row: Row) => {
const item = row.value;
const isOpen = expanded.includes(row.id);
const bodyId = `location-body-${row.id}`;
return (
<div
key={row.id}
ref={(node) => {
if (node && scrollToId.current === row.id) {
scrollToId.current = null;
node.scrollIntoView({ block: "nearest" });
}
}}
className={cn("card", "card-active", "mb-2", styles.locationCard)}
>
<div className={cn("card-header", "p-2", !isOpen && "border-bottom-0")}>
<button
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-controls={bodyId}
onClick={() => toggleExpanded(row.id)}
>
{isOpen ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<span className={cn("ms-2", styles.path)}>{item.path}</span>
<span className={cn("ms-2", "text-secondary", styles.summary)}>
{forwardSummary(item)}
</span>
</button>
{item.advancedConfig ? (
// Deliberately the same icon as the advanced-config toggle in the
// body below, so the marker reads as "this row has that section
// filled in" rather than as a decoration of its own.
<span
className={cn("ms-2", "text-secondary", styles.marker)}
role="img"
title={intl.formatMessage({ id: "location.advanced-config" })}
aria-label={intl.formatMessage({ id: "location.advanced-config" })}
>
<IconSettings size={16} />
</span>
) : null}
<button
type="button"
className="btn btn-action ms-2"
title={intl.formatMessage({ id: "action.delete" })}
aria-label={intl.formatMessage({ id: "action.delete" })}
onClick={() => handleRemove(row.id)}
>
<IconTrash size={16} className="icon" />
</button>
</div>
{isOpen && (
<div className="card-body" id={bodyId}>
<div className="row">
<div className="col-md-10">
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
type="text"
className="form-control"
placeholder="/path"
autoComplete="off"
value={item.path}
onChange={(e) => handleChange(row.id, "path", e.target.value)}
/>
</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>
</div>
<div className="row">
<div className="col-md-3">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardScheme-${row.id}`}
>
<T id="host.forward-scheme" />
</label>
<select
id={`location-forwardScheme-${row.id}`}
className="form-control"
value={item.forwardScheme}
onChange={(e) =>
handleChange(row.id, "forwardScheme", e.target.value)
}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardHost-${row.id}`}
>
<T id="proxy-host.forward-host" />
</label>
<input
id={`location-forwardHost-${row.id}`}
type="text"
className="form-control"
required
placeholder="eg: 10.0.0.1/path/"
value={item.forwardHost}
onChange={(e) =>
handleChange(row.id, "forwardHost", e.target.value)
}
/>
</div>
</div>
<div className="col-md-3">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardPort-${row.id}`}
>
<T id="host.forward-port" />
</label>
<input
id={`location-forwardPort-${row.id}`}
type="number"
min={1}
max={65535}
className="form-control"
required
placeholder="eg: 8081"
value={item.forwardPort}
onChange={(e) =>
handleChange(row.id, "forwardPort", e.target.value)
}
/>
</div>
</div>
</div>
<AccessField
name={`locations[${row.id}].accessListId`}
label="access-list"
id={`locations-access-list-${row.id}`}
onFormChange={(value) => handleAccessListChange(row.id, value)}
inheritHost
/>
{advVisible.includes(row.id) && (
<div className="">
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={170}
indentWidth={2}
value={item.advancedConfig}
onChange={(e) => handleChange(row.id, "advancedConfig", e.target.value)}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "170px",
}}
/>
</div>
)}
</div>
)}
</div>
);
})
)}
</>
);
}
@@ -10,7 +10,13 @@ interface Props {
requireDomainNames?: boolean; // used for streams
color?: string;
}
export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
export function SSLOptionsFields({
forHttp = true,
forProxyHost = false,
forceDNSForNew,
requireDomainNames,
color = "bg-cyan",
}: Props) {
const { values, setFieldValue } = useFormikContext();
const v: any = values || {};
@@ -116,10 +122,12 @@ export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDN
</div>
);
const getHttpAdvancedOptions = () =>(
const getHttpAdvancedOptions = () => (
<div>
<details>
<summary className="mb-1"><T id="domains.advanced" /></summary>
<summary className="mb-1">
<T id="domains.advanced" />
</summary>
<div className="row">
<div className="col-12">
<Field name="trustForwardedProto">
+4 -1
View File
@@ -72,7 +72,10 @@ export function SiteHeader() {
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<div className="d-md-none">
{/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: This div is not interactive. */}
<div className="p-2 pb-1 pe-1 d-flex align-items-center" onClick={e => e.stopPropagation()}>
<div
className="p-2 pb-1 pe-1 d-flex align-items-center"
onClick={(e) => e.stopPropagation()}
>
<div className="ps-2 pe-1 me-auto">
<div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary text-nowrap">
+15 -8
View File
@@ -1,6 +1,7 @@
import {
IconBook,
IconDeviceDesktop,
IconFileText,
IconHome,
IconLock,
IconSettings,
@@ -95,6 +96,12 @@ const menuItems: MenuItem[] = [
label: "auditlogs",
permissionSection: ADMIN,
},
{
to: "/logs",
icon: IconFileText,
label: "logs",
permissionSection: ADMIN,
},
{
to: "/settings",
icon: IconSettings,
@@ -143,7 +150,6 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
className="nav-link dropdown-toggle"
href={item.to}
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
role="button"
>
@@ -176,13 +182,14 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
};
export function SiteMenu() {
const closeMenu = () => setTimeout(() => {
const navbarToggler = document.querySelector<HTMLElement>(".navbar-toggler");
const navbarMenu = document.querySelector("#navbar-menu");
if (navbarToggler && navbarMenu?.classList.contains("show")) {
navbarToggler.click();
}
}, 300);
const closeMenu = () =>
setTimeout(() => {
const navbarToggler = document.querySelector<HTMLElement>(".navbar-toggler");
const navbarMenu = document.querySelector("#navbar-menu");
if (navbarToggler && navbarMenu?.classList.contains("show")) {
navbarToggler.click();
}
}, 300);
return (
<header className="navbar-expand-md">
+5 -4
View File
@@ -1,9 +1,10 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import type { Features } from "./features";
interface Props {
tableInstance: ReactTable<any>;
interface Props<TData extends RowData> {
tableInstance: ReactTable<Features, TData>;
}
function EmptyRow({ tableInstance }: Props) {
function EmptyRow<TData extends RowData>({ tableInstance }: Props<TData>) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
+2 -2
View File
@@ -1,8 +1,8 @@
import { flexRender } from "@tanstack/react-table";
import { flexRender, type RowData } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
import { EmptyRow } from "./EmptyRow";
function TableBody<T>(props: TableLayoutProps<T>) {
function TableBody<T extends RowData>(props: TableLayoutProps<T>) {
const { tableInstance, extraStyles, emptyState } = props;
const rows = tableInstance.getRowModel().rows;
@@ -1,8 +1,8 @@
import { IconArrowsSort, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { flexRender } from "@tanstack/react-table";
import { flexRender, type RowData } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
function TableHeader<T>(props: TableLayoutProps<T>) {
function TableHeader<T extends RowData>(props: TableLayoutProps<T>) {
const { tableInstance } = props;
const headerGroups = tableInstance.getHeaderGroups();
@@ -61,4 +61,4 @@ const tableEventReducer = (state: any, { type, payload }: any) => {
}
};
export { tableEvents, tableEventReducer };
export { tableEventReducer, tableEvents };
@@ -1,15 +1,16 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import type { Features } from "./features";
import { TableBody } from "./TableBody";
import { TableHeader } from "./TableHeader";
interface TableLayoutProps<TFields> {
tableInstance: ReactTable<TFields>;
interface TableLayoutProps<TFields extends RowData> {
tableInstance: ReactTable<Features, TFields>;
emptyState?: React.ReactNode;
extraStyles?: {
row: (rowData: TFields) => any | undefined;
};
}
function TableLayout<TFields>(props: TableLayoutProps<TFields>) {
function TableLayout<TFields extends RowData>(props: TableLayoutProps<TFields>) {
const hasRows = props.tableInstance.getRowModel().rows.length > 0;
return (
<div className="table-responsive">
+35
View File
@@ -0,0 +1,35 @@
import {
columnVisibilityFeature,
createSortedRowModel,
metaHelper,
rowSortingFeature,
tableFeatures,
} from "@tanstack/react-table";
interface ColumnMeta {
className?: string;
}
interface TableMeta {
isFetching?: boolean;
}
/**
* Shared TanStack Table v9 feature registration for every table in the app.
* Sorting and column visibility are used (or their APIs are called
* unconditionally, e.g. `getVisibleFlatColumns`/`getVisibleCells`) by the
* shared TableLayout/TableHeader/TableBody/EmptyData components, so every
* table instance must register them even when a particular table doesn't
* wire up controlled sorting state itself.
*/
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
columnVisibilityFeature,
columnMeta: metaHelper<ColumnMeta>(),
tableMeta: metaHelper<TableMeta>(),
});
type Features = typeof features;
export { type ColumnMeta, type Features, features, type TableMeta };
+1
View File
@@ -1,4 +1,5 @@
export * from "./Formatter";
export * from "./features";
export * from "./TableHeader";
export * from "./TableHelpers";
export * from "./TableLayout";
+3
View File
@@ -10,7 +10,10 @@ export * from "./useDeadHosts";
export * from "./useDnsProviders";
export * from "./useHealth";
export * from "./useHostReport";
export * from "./useLogSources";
export * from "./useLogTail";
export * from "./useProxyHost";
export * from "./useProxyHostLogs";
export * from "./useProxyHosts";
export * from "./useRedirectionHost";
export * from "./useRedirectionHosts";
+15
View File
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { getLogSources, type LogSources } from "src/api/backend";
const fetchLogSources = () => getLogSources();
const useLogSources = (options = {}) => {
return useQuery<LogSources, Error>({
queryKey: ["log-sources"],
queryFn: fetchLogSources,
staleTime: 30 * 1000,
...options,
});
};
export { fetchLogSources, useLogSources };
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { type GetLogTailParams, getLogTail, type LogTail } from "src/api/backend";
const POLL_INTERVAL_MS = 5000;
interface UseLogTailOptions extends GetLogTailParams {
live?: boolean;
}
// Polls for new log lines while `live` is true. React Query only runs the interval
// while the tab is focused (refetchIntervalInBackground defaults to false), so an
// idle/backgrounded browser tab never generates load.
const useLogTail = ({ live = true, ...params }: UseLogTailOptions) => {
const enabled = params.type === "host" ? Boolean(params.hostType && params.hostId && params.channel) : true;
return useQuery<LogTail, Error>({
queryKey: ["log-tail", params],
queryFn: () => getLogTail(params),
enabled,
refetchInterval: live ? POLL_INTERVAL_MS : false,
placeholderData: (previousData) => previousData,
});
};
export { useLogTail };
+12
View File
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getProxyHostLogs } from "src/api/backend";
const useProxyHostLogs = (id: number, type: "access" | "error" = "access") => {
return useQuery<{ logs: string }, Error>({
queryKey: ["proxy-host-logs", id, type],
queryFn: () => getProxyHostLogs(id, type),
staleTime: 10_000,
});
};
export { useProxyHostLogs };
+102 -83
View File
@@ -21,124 +21,143 @@ import langZh from "./lang/zh.json";
import langTr from "./lang/tr.json";
import langHu from "./lang/hu.json";
import langNo from "./lang/no.json";
import langUk from "./lang/uk.json";
import langAz from "./lang/az.json";
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
// Remember when adding to this list, also update check-locales.js script
const localeOptions = [
["en", "en-US", langEn],
["de", "de-DE", langDe],
["es", "es-ES", langEs],
["et", "et-EE", langEt],
["pt", "pt-PT", langPt],
["fr", "fr-FR", langFr],
["ga", "ga-IE", langGa],
["ja", "ja-JP", langJa],
["it", "it-IT", langIt],
["nl", "nl-NL", langNl],
["pl", "pl-PL", langPl],
["ru", "ru-RU", langRu],
["sk", "sk-SK", langSk],
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
["tr", "tr-TR", langTr],
["hu", "hu-HU", langHu],
["no", "no-NO", langNo],
["en", "en-US", langEn],
["de", "de-DE", langDe],
["es", "es-ES", langEs],
["et", "et-EE", langEt],
["pt", "pt-PT", langPt],
["fr", "fr-FR", langFr],
["ga", "ga-IE", langGa],
["ja", "ja-JP", langJa],
["it", "it-IT", langIt],
["nl", "nl-NL", langNl],
["pl", "pl-PL", langPl],
["ru", "ru-RU", langRu],
["sk", "sk-SK", langSk],
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
["tr", "tr-TR", langTr],
["hu", "hu-HU", langHu],
["no", "no-NO", langNo],
["uk", "uk-UA", langUk],
["az", "az-AZ", langAz],
["fa", "fa-IR", langFa],
];
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
const thisLocale = (locale || "en").slice(0, 2);
const thisLocale = (locale || "en").slice(0, 2);
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
return Object.assign({}, langList, langEn);
}
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
return Object.assign({}, langList, langEn);
}
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
};
const getFlagCodeForLocale = (locale?: string) => {
const thisLocale = (locale || "en").slice(0, 2);
const thisLocale = (locale || "en").slice(0, 2);
// only add to this if your flag is different from the locale code
const specialCases: Record<string, string> = {
ja: "jp", // Japan
zh: "cn", // China
vi: "vn", // Vietnam
ko: "kr", // Korea
cs: "cz", // Czechia
ga: "ie", // Ireland (Irish)
};
// only add to this if your flag is different from the locale code
const specialCases: Record<string, string> = {
ja: "jp", // Japan
zh: "cn", // China
vi: "vn", // Vietnam
ko: "kr", // Korea
cs: "cz", // Czechia
ga: "ie", // Ireland (Irish)
et: "ee", // Estonia (ISO 3166-1). "et" as a country code would be Ethiopia.
uk: "ua", // Ukraine
fa: "ir", // Iran (Persian)
};
if (specialCases[thisLocale]) {
return specialCases[thisLocale].toUpperCase();
}
return thisLocale.toUpperCase();
if (specialCases[thisLocale]) {
return specialCases[thisLocale].toUpperCase();
}
return thisLocale.toUpperCase();
};
const rtlLocales = ["fa"];
const isRTLLocale = (locale?: string) => rtlLocales.includes((locale || "en").slice(0, 2));
const applyDocumentLocale = (locale: string): void => {
document.documentElement.lang = locale;
document.documentElement.dir = isRTLLocale(locale) ? "rtl" : "ltr";
};
const getLocale = (short = false) => {
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
};
const cache = createIntlCache();
const initialMessages = loadMessages(getLocale());
applyDocumentLocale(getLocale());
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
const changeLocale = (locale: string): void => {
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
document.documentElement.lang = locale;
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
applyDocumentLocale(locale);
};
// This is a translation component that wraps the translation in a span with a data
// attribute so devs can inspect the element to see the translation ID
const T = ({
id,
data,
tData,
id,
data,
tData,
}: {
id: string;
data?: Record<string, string | number | undefined>;
tData?: Record<string, string>;
id: string;
data?: Record<string, string | number | undefined>;
tData?: Record<string, string>;
}) => {
const translatedData: Record<string, string> = {};
if (tData) {
// iterate over tData and translate each value
Object.entries(tData).forEach(([key, value]) => {
translatedData[key] = intl.formatMessage({ id: value });
});
}
return (
<span data-translation-id={id}>
{intl.formatMessage(
{ id },
{
...data,
...translatedData,
},
)}
</span>
);
const translatedData: Record<string, string> = {};
if (tData) {
// iterate over tData and translate each value
Object.entries(tData).forEach(([key, value]) => {
translatedData[key] = intl.formatMessage({ id: value });
});
}
return (
<span data-translation-id={id}>
{intl.formatMessage(
{ id },
{
...data,
...translatedData,
},
)}
</span>
);
};
//console.log("L:", localeOptions);
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
export { localeOptions, getFlagCodeForLocale, getLocale, isRTLLocale, createIntl, changeLocale, intl, T };

Some files were not shown because too many files have changed in this diff Show More