Compare commits

..

83 Commits

Author SHA1 Message Date
miloschwartz 5ca08d71f0 change restart site toast text 2026-09-17 13:31:00 -04:00
Owen 1f453dc04f Send out of address space errors to sites and clients 2026-09-17 09:24:15 -04:00
Owen 65ccd5a89d Enhance error handling for subscription lifecycle events in billing hooks 2026-09-16 12:11:25 -04:00
Owen 76a4f50ccf Update how billing is presented for license keys 2026-09-16 11:42:19 -04:00
miloschwartz 1dfe0124be add expanded resource laucher screenshot 2026-09-16 10:52:35 -04:00
miloschwartz aa52174f96 update resource launcher screenshot 2026-09-16 10:52:35 -04:00
Owen e23ae5707f Link direct to self hosted 2026-09-16 10:43:46 -04:00
Owen 17053ac2bd Fix agent display again 2026-09-16 10:38:52 -04:00
Owen 735787b183 Fix #3778 2026-09-16 10:11:20 -04:00
Owen 262ca8a1d0 Add domain validation for inference mode in resource forms 2026-09-16 09:41:46 -04:00
Owen Schwartz 66c9bdbfa3 Merge pull request #3774 from fosrl/dev
Make migration idempotent
2026-09-16 09:11:14 -04:00
Owen 4716a2a647 Make migration idempotent
Ref #3759
2026-09-16 09:08:20 -04:00
Owen Schwartz 847f44197b Merge pull request #3772 from Hayyan612/fix/site-type-badge-fallback
fix(sites): fall back to newtVersion so the Type badge is never empty
2026-09-16 08:59:07 -04:00
Owen Schwartz 6aacd4d185 Merge pull request #3770 from Hayyan612/fix/systemd-exec-path
fix(ui): point the manual systemd unit at the installed CLI path
2026-09-16 08:57:37 -04:00
Owen Schwartz ab3db3bc68 Merge pull request #3769 from Hayyan612/fix/backup-filename-timestamp
fix(setup): correct month index and zero-pad database backup file names
2026-09-16 08:57:12 -04:00
Hayyan Hajwani b52baceb50 fix(sites): fall back to newtVersion so the Type badge is never empty
The site overview rendered an empty badge for any site whose newt has not
reported the newer agent fields. The early return only bails out when both
agent and newtVersion are missing, so a site with newtVersion set but agent
null fell through to a badge whose label came solely from agent and whose
version came solely from agentVersion, leaving both blank.

Label such a site Newt and fall back to newtVersion for the version. Updating
the newt populated the new fields, which is why the badge appeared to fix
itself on upgrade.

Closes #3766
2026-09-16 13:55:24 +05:30
Hayyan Hajwani 0d2c8a37ef fix(ui): point the manual systemd unit at the installed CLI path
The service file offered on the site install screen hardcoded
/home/owen/fossorial/cli/bin/pangolin, a developer machine path, so the unit
fails to start on a normal install.

get-cli.sh installs to /usr/local/bin ("Prefer /usr/local/bin for system-wide
installation"), which is also where the bare `pangolin` calls in the surrounding
commands resolve from.

Closes #3768
2026-09-16 13:54:14 +05:30
Hayyan Hajwani 9298ec7cdb fix(setup): correct month index and zero-pad database backup file names
Backup names were built inline from Date#getMonth, which is zero-indexed, so a
backup taken on 12 September 2026 was written as db_2026-8-12_20-35-56.sqlite.
No field was zero-padded either, giving names like db_2026-8-12_20-36-2.sqlite.

Extract formatBackupTimestamp into server/lib and use it from both places that
built the string: the backupDb helper in migrationsSqlite.ts and the inline copy
in the 1.0.0-beta9 setup script. Padding every field also makes the names sort
lexicographically in the order the backups were taken.

Adds tests covering both reported names, single-digit padding and sort order.
Reverting the helper to the old formula fails them with the exact name from the
report.
2026-09-16 13:46:57 +05:30
Owen Schwartz 0fdff2feee Merge pull request #3765 from fosrl/dev
Show the version
2026-09-15 22:31:22 -04:00
Owen 8e042e6433 Show the version 2026-09-15 22:31:05 -04:00
Owen Schwartz 883ad14326 Merge pull request #3764 from fosrl/dev
fix premature migration execution
2026-09-15 22:30:19 -04:00
miloschwartz 3cb41211ee fix premature migration execution 2026-09-15 20:44:17 -04:00
Owen Schwartz ee4a1a6b18 Merge pull request #3758 from fosrl/dev
Update link in reference deployment
2026-09-15 17:17:22 -04:00
Owen ee3f9efa27 Update link 2026-09-15 17:16:57 -04:00
Owen Schwartz a10972990a Merge pull request #3757 from fosrl/dev
Install go 1.26
2026-09-15 16:57:15 -04:00
Owen c32ef54428 Install go 1.26 2026-09-15 16:56:46 -04:00
Owen Schwartz 6d6e105711 Merge pull request #3756 from fosrl/dev
1.23.0
2026-09-15 16:48:42 -04:00
Owen Schwartz bda09a1b74 Merge pull request #3755 from fosrl/l10n_dev
New Crowdin updates
2026-09-15 16:43:18 -04:00
Owen Schwartz 178d1d9779 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-09-15 16:42:51 -04:00
Owen Schwartz d311eb6e8c New translations en-us.json (Chinese Simplified)
[ci skip]
2026-09-15 16:42:49 -04:00
Owen Schwartz 829680e984 New translations en-us.json (Turkish)
[ci skip]
2026-09-15 16:42:47 -04:00
Owen Schwartz be10dcd26e New translations en-us.json (Russian)
[ci skip]
2026-09-15 16:42:45 -04:00
Owen Schwartz cc23e8cf5d New translations en-us.json (Portuguese)
[ci skip]
2026-09-15 16:42:42 -04:00
Owen Schwartz fc77b5bb3e New translations en-us.json (Polish)
[ci skip]
2026-09-15 16:42:40 -04:00
Owen Schwartz 651acae326 New translations en-us.json (Dutch)
[ci skip]
2026-09-15 16:42:38 -04:00
Owen Schwartz 75332a1d1f New translations en-us.json (Korean)
[ci skip]
2026-09-15 16:42:36 -04:00
Owen Schwartz 0f1631f592 New translations en-us.json (Italian)
[ci skip]
2026-09-15 16:42:34 -04:00
Owen Schwartz f1a303de14 New translations en-us.json (German)
[ci skip]
2026-09-15 16:42:31 -04:00
Owen Schwartz 191c062cec New translations en-us.json (Danish)
[ci skip]
2026-09-15 16:42:29 -04:00
Owen Schwartz 1839ea7ca3 New translations en-us.json (Czech)
[ci skip]
2026-09-15 16:42:26 -04:00
Owen Schwartz a3c2c98c5e New translations en-us.json (Bulgarian)
[ci skip]
2026-09-15 16:42:24 -04:00
Owen Schwartz 43e42f90f8 New translations en-us.json (Spanish)
[ci skip]
2026-09-15 16:42:22 -04:00
Owen Schwartz a954ba349b New translations en-us.json (French)
[ci skip]
2026-09-15 16:42:20 -04:00
Owen Schwartz f07aef4ace New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-09-15 16:35:29 -04:00
Owen Schwartz 4afa01aba4 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-09-15 16:35:27 -04:00
Owen Schwartz 9b15e99cfb New translations en-us.json (Turkish)
[ci skip]
2026-09-15 16:35:24 -04:00
Owen Schwartz a322d0479c New translations en-us.json (Russian)
[ci skip]
2026-09-15 16:35:22 -04:00
Owen Schwartz bb9761a171 New translations en-us.json (Portuguese)
[ci skip]
2026-09-15 16:35:19 -04:00
Owen Schwartz af4d80edec New translations en-us.json (Polish)
[ci skip]
2026-09-15 16:35:17 -04:00
Owen Schwartz da434f63b4 New translations en-us.json (Dutch)
[ci skip]
2026-09-15 16:35:15 -04:00
Owen Schwartz 9718c3549b New translations en-us.json (Korean)
[ci skip]
2026-09-15 16:35:13 -04:00
Owen Schwartz d550eef6c2 New translations en-us.json (Italian)
[ci skip]
2026-09-15 16:35:11 -04:00
Owen Schwartz c76b591f9f New translations en-us.json (German)
[ci skip]
2026-09-15 16:35:08 -04:00
Owen Schwartz a1b7c69ba5 New translations en-us.json (Danish)
[ci skip]
2026-09-15 16:35:06 -04:00
Owen Schwartz 20e5d537b0 New translations en-us.json (Czech)
[ci skip]
2026-09-15 16:35:04 -04:00
Owen Schwartz 197cfdc339 New translations en-us.json (Bulgarian)
[ci skip]
2026-09-15 16:35:01 -04:00
Owen Schwartz 418bb26ee6 New translations en-us.json (Spanish)
[ci skip]
2026-09-15 16:34:59 -04:00
Owen Schwartz 4dfa81416b New translations en-us.json (French)
[ci skip]
2026-09-15 16:34:57 -04:00
Owen 324f3e50ff Add license tiers 2026-09-15 16:25:53 -04:00
miloschwartz 64bb6d9f9c supress healthcheck log message 2026-09-15 15:02:37 -04:00
Owen e54d1aa73e Include .npmrc 2026-09-15 10:59:47 -04:00
Owen c6c12f1dcb Support _FILE env vars
Closes https://github.com/fosrl/docs-v2/issues/141
2026-09-15 10:56:39 -04:00
Owen 4b30911f06 Update renamed table to month_grid 2026-09-15 10:25:34 -04:00
Owen c6c443f0e0 Remove optional before prefault for zod v4 2026-09-15 10:25:21 -04:00
Owen 24a06dd8ca Rename package import 2026-09-15 10:25:06 -04:00
Owen c11bbe670f Remove optional() where unneeded for zod 4 2026-09-15 10:23:37 -04:00
Owen 7ed646c02b Resolve eslint old ts issues and revert tanstack table 2026-09-15 10:23:22 -04:00
Owen 9d5dd526bc Pin typescript 2026-09-15 10:06:16 -04:00
Owen 9e25650f22 Fix service missing traefik issues 2026-09-15 10:04:00 -04:00
Owen 143b4cf757 Atomically write files to avoid races with traefik 2026-09-15 10:04:00 -04:00
Owen Schwartz 25b151a021 Merge pull request #3748 from fosrl/dependabot/npm_and_yarn/npm-dependencies-eb7d53c756
Bump the npm-dependencies group across 1 directory with 29 updates
2026-09-15 10:03:44 -04:00
dependabot[bot] c62fb6cbe0 Bump the npm-dependencies group across 1 directory with 29 updates
Bumps the npm-dependencies group with 29 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1121.0` | `3.1131.0` |
| [@node-rs/argon2](https://github.com/napi-rs/node-rs) | `2.2.0` | `2.2.1` |
| [@simplewebauthn/browser](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/browser) | `13.3.0` | `14.0.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.3` | `14.0.1` |
| [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) | `8.21.3` | `9.2.4` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.38.0` | `1.45.0` |
| [next](https://github.com/vercel/next.js) | `16.3.4` | `16.3.5` |
| [next-intl](https://github.com/amannn/next-intl) | `4.14.1` | `4.14.4` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.1.1` | `10.0.9` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.51.4` | `5.52.1` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.8` | `19.3.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.18` | `19.3.0` |
| [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `9.14.0` | `10.0.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.8` | `19.3.0` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.5` | `19.3.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.87.0` | `7.88.0` |
| [stripe](https://github.com/stripe/stripe-node) | `22.6.0` | `22.6.2` |
| [yaml](https://github.com/eemeli/yaml) | `2.9.0` | `2.9.1` |
| [zod](https://github.com/colinhacks/zod) | `4.5.4` | `4.6.2` |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `2.23.0` | `2.24.1` |
| [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.9.3` | `6.9.5` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.0` | `26.5.1` |
| [eslint](https://github.com/eslint/eslint) | `10.9.1` | `10.10.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.5` |
| [postcss](https://github.com/postcss/postcss) | `8.5.26` | `8.5.28` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.9.3` | `6.9.5` |
| [tsc-alias](https://github.com/justkey007/tsc-alias) | `1.9.2` | `1.9.5` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.68.0` | `8.70.0` |



Updates `@aws-sdk/client-s3` from 3.1121.0 to 3.1131.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1131.0/clients/client-s3)

Updates `@node-rs/argon2` from 2.2.0 to 2.2.1
- [Release notes](https://github.com/napi-rs/node-rs/releases)
- [Commits](https://github.com/napi-rs/node-rs/compare/@node-rs/argon2@2.2.0...@node-rs/argon2@2.2.1)

Updates `@simplewebauthn/browser` from 13.3.0 to 14.0.0
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v14.0.0/packages/browser)

Updates `@simplewebauthn/server` from 13.3.3 to 14.0.1
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v14.0.1/packages/server)

Updates `@tanstack/react-table` from 8.21.3 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 `lucide-react` from 1.38.0 to 1.45.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.45.0/packages/lucide-react)

Updates `next` from 16.3.4 to 16.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.4...v16.3.5)

Updates `next-intl` from 4.14.1 to 4.14.4
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.14.1...v4.14.4)

Updates `nodemailer` from 9.1.1 to 10.0.9
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.1.1...v10.0.9)

Updates `posthog-node` from 5.51.4 to 5.52.1
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.52.1/packages/node)

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 `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-day-picker` from 9.14.0 to 10.0.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.1/packages/react-day-picker)

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 `@types/react-dom` from 19.2.5 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `react-hook-form` from 7.87.0 to 7.88.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.87.0...v7.88.0)

Updates `stripe` from 22.6.0 to 22.6.2
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v22.6.0...v22.6.2)

Updates `yaml` from 2.9.0 to 2.9.1
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.9.0...v2.9.1)

Updates `zod` from 4.5.4 to 4.6.2
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.5.4...v4.6.2)

Updates `@dotenvx/dotenvx` from 2.23.0 to 2.24.1
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v2.23.0...v2.24.1)

Updates `@react-email/ui` from 6.9.3 to 6.9.5
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.9.5/packages/ui)

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

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

Updates `@types/react-dom` from 19.2.5 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `eslint` from 10.9.1 to 10.10.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.1...v10.10.0)

Updates `eslint-config-next` from 16.3.3 to 16.3.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.5/packages/eslint-config-next)

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)

Updates `react-email` from 6.9.3 to 6.9.5
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@6.9.5/packages/react-email)

Updates `tsc-alias` from 1.9.2 to 1.9.5
- [Release notes](https://github.com/justkey007/tsc-alias/releases)
- [Commits](https://github.com/justkey007/tsc-alias/compare/v1.9.2...v1.9.5)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

Updates `typescript-eslint` from 8.68.0 to 8.70.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1131.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 2.24.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@node-rs/argon2"
  dependency-version: 2.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@simplewebauthn/browser"
  dependency-version: 14.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@simplewebauthn/server"
  dependency-version: 14.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-table"
  dependency-version: 9.2.4
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint
  dependency-version: 10.10.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint-config-next
  dependency-version: 16.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: next
  dependency-version: 16.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: next-intl
  dependency-version: 4.14.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: nodemailer
  dependency-version: 10.0.8
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: postcss
  dependency-version: 8.5.28
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: posthog-node
  dependency-version: 5.52.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-day-picker
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-email
  dependency-version: 6.9.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.88.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: stripe
  dependency-version: 22.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: tsc-alias
  dependency-version: 1.9.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.70.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: yaml
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: zod
  dependency-version: 4.6.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-15 14:00:26 +00:00
Owen Schwartz e561c9f334 Merge pull request #3744 from fosrl/dependabot/npm_and_yarn/next-16.3.4
Bump next from 16.3.3 to 16.3.4
2026-09-15 09:45:14 -04:00
dependabot[bot] 2b970ce6e7 Bump next from 16.3.3 to 16.3.4
Bumps [next](https://github.com/vercel/next.js) from 16.3.3 to 16.3.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-15 13:41:09 +00:00
Owen Schwartz e8cb0297a1 Merge pull request #3735 from fosrl/dependabot/go_modules/install/go-install-dependencies-19034c9a63
Bump golang.org/x/term from 0.45.0 to 0.46.0 in /install in the go-install-dependencies group across 1 directory
2026-09-15 09:40:28 -04:00
Owen Schwartz 4f7e993e70 Merge pull request #3714 from aithal007/fix/ci-pin-node-runtime-version
fix(ci): pin Node to 24.18.1 to match runtime and fix flaky test job
2026-09-15 09:40:01 -04:00
Owen Schwartz 8dc3a1378a Merge pull request #3721 from fosrl/dependabot/npm_and_yarn/nodemailer-9.1.1
Bump nodemailer from 9.1.0 to 9.1.1
2026-09-15 09:38:47 -04:00
Owen Schwartz 3713b714e4 Merge pull request #3722 from fosrl/dependabot/npm_and_yarn/sharp-0.35.4
Bump sharp from 0.35.3 to 0.35.4
2026-09-15 09:38:17 -04:00
dependabot[bot] 098157f671 Bump golang.org/x/term
Bumps the go-install-dependencies group with 1 update in the /install directory: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/term/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-install-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-15 01:33:16 +00:00
dependabot[bot] ea1b3e5270 Bump nodemailer from 9.1.0 to 9.1.1
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.1.0 to 9.1.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.1.0...v9.1.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 21:55:28 +00:00
dependabot[bot] 899620a890 Bump sharp from 0.35.3 to 0.35.4
Bumps [sharp](https://github.com/lovell/sharp) from 0.35.3 to 0.35.4.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.3...v0.35.4)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 04:34:02 +00:00
Parikshith c19cb706e8 fix(ci): harden app-readiness PID check; clarify version-source comment
Address review feedback on the readiness step:
- Read the PID safely (`cat app.pid 2>/dev/null || true`) and treat a
  missing/empty PID as "not running", so a failed nohup no longer produces a
  confusing `kill` usage error and misleading message.
- Guard the diagnostic dump behind a helper so a missing app.log prints
  "(app.log not found)" instead of erroring.
- The exact 24.18.1 pin comes from the Dockerfiles; .nvmrc only pins the 24
  major. Corrected the comments to say so rather than citing .nvmrc as the
  source of the patch version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:28:35 +05:30
Parikshith 938d7f145c fix(ci): pin Node to 24.18.1 to match runtime; surface app startup logs
The `test` job intermittently fails at "Wait for app availability" because
`npm run dev` crashes on startup:

    node::RemoveEnvironmentCleanupHook(...) at ../src/api/hooks.cc:142
    Assertion failed: (env) != nullptr
      Statement::~Statement()  [better-sqlite3/build/Release/better_sqlite3.node]

Node >= 24.19.0 added node::ObjectWrap cleanup hooks that are incompatible
with NAN-style native addons such as better-sqlite3 (11.9.1, this project's
pinned version), tripping that assertion on teardown. It is intermittent (a
race during native cleanup), so the job fails on some runs and passes on
others.

The app ships on Node 24.18.1 (both Dockerfiles and .nvmrc), which predates
the breaking change, so production is unaffected. Only CI hit it, because
`setup-node` with `node-version: '24'` floats to the latest 24.x (24.20.0 at
time of failure). Pinning CI to 24.18.1 makes it test the version that
actually ships and avoids the regression.

Also capture `npm run dev` output to app.log and fail fast (printing it) if
the process exits early, so a future startup failure is diagnosable instead
of surfacing only as "App failed to start" after a 25s wait.

(Longer term, upgrading better-sqlite3 to v12+ restores Node 24.19+ support;
pinning the runtime is the minimal, prod-matching fix.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:22:39 +05:30
80 changed files with 3663 additions and 2379 deletions
+1 -1
View File
@@ -266,7 +266,7 @@ jobs:
- name: Install Go - name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: 1.25 go-version: 1.26
- name: Update version in package.json - name: Update version in package.json
run: | run: |
+4 -1
View File
@@ -26,7 +26,10 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: '24' # Match the version the app ships on (Dockerfile /
# Dockerfile.dev both use 24.18.1) rather than floating
# to the latest 24.x.
node-version: '24.18.1'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+24 -3
View File
@@ -19,7 +19,13 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: '24' # Pin to the version the app actually ships on (Dockerfile /
# Dockerfile.dev both use 24.18.1; .nvmrc pins the 24 major).
# A bare '24' floats to the latest 24.x; Node >= 24.19.0 added
# node::ObjectWrap cleanup hooks that crash better-sqlite3 on teardown
# ("Assertion failed: (env) != nullptr" in RemoveEnvironmentCleanupHook),
# which intermittently kills `npm run dev` in the step below.
node-version: '24.18.1'
- name: Copy config file - name: Copy config file
run: cp config/config.example.yml config/config.yml run: cp config/config.example.yml config/config.yml
@@ -43,19 +49,34 @@ jobs:
run: npx tsc --noEmit run: npx tsc --noEmit
- name: Start app in background - name: Start app in background
run: nohup npm run dev & run: |
nohup npm run dev > app.log 2>&1 &
echo $! > app.pid
- name: Wait for app availability - name: Wait for app availability
run: | run: |
print_log() {
if [ -f app.log ]; then cat app.log; else echo "(app.log not found)"; fi
}
for i in {1..5}; do for i in {1..5}; do
if curl --silent --fail http://localhost:3002/auth/login; then if curl --silent --fail http://localhost:3002/auth/login; then
echo "App is up" echo "App is up"
exit 0 exit 0
fi fi
# Fail fast (and show why) if the process is gone. Read the PID
# safely: if it's missing/empty (e.g. nohup never started), treat
# that as "not running" instead of passing a bad arg to kill.
pid="$(cat app.pid 2>/dev/null || true)"
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
echo "App process exited before becoming available. Output:"
print_log
exit 1
fi
echo "Waiting for the app... attempt $i" echo "Waiting for the app... attempt $i"
sleep 5 sleep 5
done done
echo "App failed to start" echo "App failed to start. Output:"
print_log
exit 1 exit 1
build-sqlite: build-sqlite:
+4
View File
@@ -0,0 +1,4 @@
# typescript-eslint@8.70.0 declares a peer range of typescript "<6.1.0" and
# hasn't caught up to typescript@7.x yet, even though it works fine against it
# in practice. Without this, `npm install`/`npm ci` fail with ERESOLVE.
legacy-peer-deps=true
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./ COPY package*.json .npmrc ./
FROM base AS builder-dev FROM base AS builder-dev
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++
COPY package*.json ./ COPY package*.json .npmrc ./
# Install dependencies # Install dependencies
RUN npm ci RUN npm ci
+1
View File
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
* Create reusable views for common access patterns * Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" /> <img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
## Download Clients ## Download Clients
+1 -1
View File
@@ -1,3 +1,3 @@
## Example Docker Reference HA Deployment ## Example Docker Reference HA Deployment
This directory contains basic config for a highly available deployment of Pangolin with two nodes. For more information [refer to the docs](/self-host/clustering/understanding-clustering). This directory contains a basic reference config for a highly available deployment of Pangolin with two nodes. For more information [refer to the docs](https://docs.pangolin.net/self-host/clustering/understanding-clustering).
+3 -3
View File
@@ -1,11 +1,11 @@
module installer module installer
go 1.25.0 go 1.26.0
require ( require (
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.45.0 golang.org/x/term v0.46.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
) )
+4 -4
View File
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Няма намерени сайтове.", "sitesNotFound": "Няма намерени сайтове.",
"pangolinServerAdmin": "Администратор на сървър - Панголин", "pangolinServerAdmin": "Администратор на сървър - Панголин",
"licenseTierProfessional": "Професионален лиценз", "licenseTierProfessional": "Професионален лиценз",
"licenseTierEnterprise": "Предприятие лиценз", "licenseTierEnterprise": "Предприятие",
"licenseTierPersonal": "Персонален лиценз", "licenseTierPersonal": "Личен",
"licenseTierTier1": "Начален",
"licenseTierTier2": "Мащаб",
"licensed": "Лицензиран", "licensed": "Лицензиран",
"yes": "Да", "yes": "Да",
"no": "Не", "no": "Не",
+5 -3
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Nebyly nalezeny žádné stránky.", "sitesNotFound": "Nebyly nalezeny žádné stránky.",
"pangolinServerAdmin": "Správce serveru - Pangolin", "pangolinServerAdmin": "Správce serveru - Pangolin",
"licenseTierProfessional": "Profesionální licence", "licenseTierProfessional": "Profesionální licence",
"licenseTierEnterprise": "Podniková licence", "licenseTierEnterprise": "Podniky",
"licenseTierPersonal": "Osobní licence", "licenseTierPersonal": "Osobní",
"licenseTierTier1": "Počáteční",
"licenseTierTier2": "Měřítko",
"licensed": "Licencováno", "licensed": "Licencováno",
"yes": "Ano", "yes": "Ano",
"no": "Ne", "no": "Ne",
@@ -3979,7 +3981,7 @@
"tpmAvailable": "TPM k dispozici", "tpmAvailable": "TPM k dispozici",
"windowsAntivirusEnabled": "Antivirus povolen", "windowsAntivirusEnabled": "Antivirus povolen",
"macosSipEnabled": "Ochrana systémové integrity (SIP)", "macosSipEnabled": "Ochrana systémové integrity (SIP)",
"macosGatekeeperEnabled": "Gatekeeper", "macosGatekeeperEnabled": "Strážce",
"macosFirewallStealthMode": "Režim neviditelnosti firewallu", "macosFirewallStealthMode": "Režim neviditelnosti firewallu",
"linuxAppArmorEnabled": "Pancíř aplikace", "linuxAppArmorEnabled": "Pancíř aplikace",
"linuxSELinuxEnabled": "SELinux", "linuxSELinuxEnabled": "SELinux",
+5 -3
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Ingen sites fundet.", "sitesNotFound": "Ingen sites fundet.",
"pangolinServerAdmin": "Serveradmin - Pangolin", "pangolinServerAdmin": "Serveradmin - Pangolin",
"licenseTierProfessional": "Professionel licens", "licenseTierProfessional": "Professionel licens",
"licenseTierEnterprise": "Enterprise-licens", "licenseTierEnterprise": "Bedrift",
"licenseTierPersonal": "Personlig licens", "licenseTierPersonal": "Personlig",
"licenseTierTier1": "Begynder",
"licenseTierTier2": "Skala",
"licensed": "Licenseret", "licensed": "Licenseret",
"yes": "Ja", "yes": "Ja",
"no": "Nej", "no": "Nej",
@@ -2068,7 +2070,7 @@
"aiUsageFilterNotFound": "Ingen valg fundet", "aiUsageFilterNotFound": "Ingen valg fundet",
"aiUsageResetFilters": "Nulstil Filtre", "aiUsageResetFilters": "Nulstil Filtre",
"aiUsageRefresh": "Opdater", "aiUsageRefresh": "Opdater",
"aiUsageTokenTypePrompt": "Prompt", "aiUsageTokenTypePrompt": "Vis",
"aiUsageTokenTypeCacheRead": "Cache læs", "aiUsageTokenTypeCacheRead": "Cache læs",
"aiUsageTokenTypeCacheWrite": "Cache skriv", "aiUsageTokenTypeCacheWrite": "Cache skriv",
"aiUsageTokenTypeCompletion": "Komplettering", "aiUsageTokenTypeCompletion": "Komplettering",
+5 -3
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Keine Standorte gefunden.", "sitesNotFound": "Keine Standorte gefunden.",
"pangolinServerAdmin": "Server-Admin - Pangolin", "pangolinServerAdmin": "Server-Admin - Pangolin",
"licenseTierProfessional": "Professional Lizenz", "licenseTierProfessional": "Professional Lizenz",
"licenseTierEnterprise": "Enterprise Lizenz", "licenseTierEnterprise": "Firma",
"licenseTierPersonal": "Persönliche Lizenz", "licenseTierPersonal": "Persönlich",
"licenseTierTier1": "Starter",
"licenseTierTier2": "Maßstab",
"licensed": "Lizenziert", "licensed": "Lizenziert",
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
@@ -3205,7 +3207,7 @@
"idpAzureClientIdDescription2": "Azure App Registration Client ID", "idpAzureClientIdDescription2": "Azure App Registration Client ID",
"idpAzureClientSecretDescription2": "Azure App Registration Client Geheimnis", "idpAzureClientSecretDescription2": "Azure App Registration Client Geheimnis",
"idpGoogleDescription": "Google OAuth2/OIDC Provider", "idpGoogleDescription": "Google OAuth2/OIDC Provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC-Anbieter",
"subnet": "Subnetz", "subnet": "Subnetz",
"utilitySubnet": "Nutzsubnetz", "utilitySubnet": "Nutzsubnetz",
"subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.",
+8 -3
View File
@@ -132,7 +132,7 @@
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.", "siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.", "siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
"siteRestarted": "Site restarted", "siteRestarted": "Site restarted",
"siteRestartedDescription": "The WireGuard tunnel has been restarted.", "siteRestartedDescription": "The site has been restarted.",
"siteErrorRestart": "Failed to restart site", "siteErrorRestart": "Failed to restart site",
"siteErrorRestartDescription": "An error occurred while restarting the site.", "siteErrorRestartDescription": "An error occurred while restarting the site.",
"siteSettingDescription": "Configure the settings on the site", "siteSettingDescription": "Configure the settings on the site",
@@ -234,6 +234,9 @@
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerTitle": "Zero-Trust Private Access",
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
"licenseBillingBannerTitle": "Manage License Billing",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Error deleting resource",
@@ -1118,8 +1121,10 @@
"sitesNotFound": "No sites found.", "sitesNotFound": "No sites found.",
"pangolinServerAdmin": "Server Admin - Pangolin", "pangolinServerAdmin": "Server Admin - Pangolin",
"licenseTierProfessional": "Professional License", "licenseTierProfessional": "Professional License",
"licenseTierEnterprise": "Enterprise License", "licenseTierEnterprise": "Enterprise",
"licenseTierPersonal": "Personal License", "licenseTierPersonal": "Personal",
"licenseTierTier1": "Starter",
"licenseTierTier2": "Scale",
"licensed": "Licensed", "licensed": "Licensed",
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Sitios no encontrados.", "sitesNotFound": "Sitios no encontrados.",
"pangolinServerAdmin": "Admin Servidor - Pangolin", "pangolinServerAdmin": "Admin Servidor - Pangolin",
"licenseTierProfessional": "Licencia profesional", "licenseTierProfessional": "Licencia profesional",
"licenseTierEnterprise": "Licencia Enterprise", "licenseTierEnterprise": "Empresa",
"licenseTierPersonal": "Licencia personal", "licenseTierPersonal": "Uso personal",
"licenseTierTier1": "Iniciador",
"licenseTierTier2": "Escala",
"licensed": "Licenciado", "licensed": "Licenciado",
"yes": "Sí", "yes": "Sí",
"no": "Nu", "no": "Nu",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Aucun site trouvé.", "sitesNotFound": "Aucun site trouvé.",
"pangolinServerAdmin": "Admin Serveur - Pangolin", "pangolinServerAdmin": "Admin Serveur - Pangolin",
"licenseTierProfessional": "Licence Professionnelle", "licenseTierProfessional": "Licence Professionnelle",
"licenseTierEnterprise": "Licence Entreprise", "licenseTierEnterprise": "Entreprise",
"licenseTierPersonal": "Licence personnelle", "licenseTierPersonal": "Personnel",
"licenseTierTier1": "Démarrage",
"licenseTierTier2": "Échelle",
"licensed": "Sous licence", "licensed": "Sous licence",
"yes": "Oui", "yes": "Oui",
"no": "Non", "no": "Non",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Nessun sito trovato.", "sitesNotFound": "Nessun sito trovato.",
"pangolinServerAdmin": "Server Admin - Pangolina", "pangolinServerAdmin": "Server Admin - Pangolina",
"licenseTierProfessional": "Licenza Professional", "licenseTierProfessional": "Licenza Professional",
"licenseTierEnterprise": "Licenza Enterprise", "licenseTierEnterprise": "Impresa",
"licenseTierPersonal": "Licenza Personale", "licenseTierPersonal": "Personale",
"licenseTierTier1": "Avviatore",
"licenseTierTier2": "Scala",
"licensed": "Con Licenza", "licensed": "Con Licenza",
"yes": "Sì", "yes": "Sì",
"no": "No", "no": "No",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "사이트를 찾을 수 없습니다.", "sitesNotFound": "사이트를 찾을 수 없습니다.",
"pangolinServerAdmin": "서버 관리자 - 판골린", "pangolinServerAdmin": "서버 관리자 - 판골린",
"licenseTierProfessional": "전문 라이센스", "licenseTierProfessional": "전문 라이센스",
"licenseTierEnterprise": "기업 라이선스", "licenseTierEnterprise": "기업",
"licenseTierPersonal": "개인 라이선스", "licenseTierPersonal": "개인",
"licenseTierTier1": "스타터",
"licenseTierTier2": "스케일",
"licensed": "라이센스", "licensed": "라이센스",
"yes": "예", "yes": "예",
"no": "아니요", "no": "아니요",
+20 -18
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Ingen områder funnet.", "sitesNotFound": "Ingen områder funnet.",
"pangolinServerAdmin": "Server Admin - Pangolin", "pangolinServerAdmin": "Server Admin - Pangolin",
"licenseTierProfessional": "Profesjonell lisens", "licenseTierProfessional": "Profesjonell lisens",
"licenseTierEnterprise": "Bedriftslisens", "licenseTierEnterprise": "Bedrift",
"licenseTierPersonal": "Personlig lisens", "licenseTierPersonal": "Personlig",
"licenseTierTier1": "Begynner",
"licenseTierTier2": "Skala",
"licensed": "Lisensiert", "licensed": "Lisensiert",
"yes": "Ja", "yes": "Ja",
"no": "Nei", "no": "Nei",
@@ -2816,7 +2818,7 @@
"roleTextImportPreview": "Forhåndsvisning", "roleTextImportPreview": "Forhåndsvisning",
"roleTextImportItemCount": "{count, plural, =0 {Ingen elementer å importere} one {ett element å importere} other {# elementer å importere}}", "roleTextImportItemCount": "{count, plural, =0 {Ingen elementer å importere} one {ett element å importere} other {# elementer å importere}}",
"roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt", "roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt",
"roleTextImportConfirm": "Import", "roleTextImportConfirm": "Importer",
"roleTextImportInvalidFile": "Ustøttet filtype", "roleTextImportInvalidFile": "Ustøttet filtype",
"roleTextImportInvalidFileDescription": "Bare .txt og .csv filer er støttet.", "roleTextImportInvalidFileDescription": "Bare .txt og .csv filer er støttet.",
"roleTextImportEmpty": "Ingen elementer funnet i filen", "roleTextImportEmpty": "Ingen elementer funnet i filen",
@@ -3093,7 +3095,7 @@
"regionAfrica": "Afrika", "regionAfrica": "Afrika",
"regionNorthernAfrica": "[country name] Nord-Afrika", "regionNorthernAfrica": "[country name] Nord-Afrika",
"regionEasternAfrica": "Øst-Afrika", "regionEasternAfrica": "Øst-Afrika",
"regionMiddleAfrica": "Middle Africa", "regionMiddleAfrica": "Midt-Afrika",
"regionSouthernAfrica": "Sør-Afrika", "regionSouthernAfrica": "Sør-Afrika",
"regionWesternAfrica": "[country name] Vest-Afrika", "regionWesternAfrica": "[country name] Vest-Afrika",
"regionAmericas": "Amerika", "regionAmericas": "Amerika",
@@ -3112,10 +3114,10 @@
"regionNorthernEurope": "Nord-Europa", "regionNorthernEurope": "Nord-Europa",
"regionSouthernEurope": "Sørlige Europa", "regionSouthernEurope": "Sørlige Europa",
"regionWesternEurope": "Vest-Europa", "regionWesternEurope": "Vest-Europa",
"regionOceania": "Oceania", "regionOceania": "Oseania",
"regionAustraliaAndNewZealand": "Australia og New Zealand", "regionAustraliaAndNewZealand": "Australia og New Zealand",
"regionMelanesia": "Melanesia", "regionMelanesia": "Melanesia",
"regionMicronesia": "Micronesia", "regionMicronesia": "Mikronesia",
"regionPolynesia": "Polynesia", "regionPolynesia": "Polynesia",
"managedSelfHosted": { "managedSelfHosted": {
"title": "Administrert selv-hostet", "title": "Administrert selv-hostet",
@@ -3205,7 +3207,7 @@
"idpAzureClientIdDescription2": "Azure App registrerings klient-ID", "idpAzureClientIdDescription2": "Azure App registrerings klient-ID",
"idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig", "idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig",
"idpGoogleDescription": "Google OAuth2/OIDC leverandør", "idpGoogleDescription": "Google OAuth2/OIDC leverandør",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC-leverandør",
"subnet": "Subnett", "subnet": "Subnett",
"utilitySubnet": "Nyttesubnett", "utilitySubnet": "Nyttesubnett",
"subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.",
@@ -3220,7 +3222,7 @@
"authPageBrandingRemoveTitle": "Fjern markedsføring for autentiseringsside", "authPageBrandingRemoveTitle": "Fjern markedsføring for autentiseringsside",
"authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?", "authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?",
"authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging", "authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging",
"brandingLogoURL": "Logo URL", "brandingLogoURL": "Logo-URL",
"brandingLogoURLOrPath": "Logoen URL eller sti", "brandingLogoURLOrPath": "Logoen URL eller sti",
"brandingLogoPathDescription": "Skriv inn en URL eller en lokal bane.", "brandingLogoPathDescription": "Skriv inn en URL eller en lokal bane.",
"brandingLogoURLDescription": "Skriv inn en offentlig tilgjengelig nettadresse til din logobilde.", "brandingLogoURLDescription": "Skriv inn en offentlig tilgjengelig nettadresse til din logobilde.",
@@ -3360,7 +3362,7 @@
"resourceHeaderAuthSetupTitleDescription": "Angi grunnleggende auth legitimasjon (brukernavn og passord) for å beskytte denne ressursen med HTTP Header autentisering. Tilgang til det ved hjelp av formatet https://username:password@resource.example.com", "resourceHeaderAuthSetupTitleDescription": "Angi grunnleggende auth legitimasjon (brukernavn og passord) for å beskytte denne ressursen med HTTP Header autentisering. Tilgang til det ved hjelp av formatet https://username:password@resource.example.com",
"resourceHeaderAuthSubmit": "Angi topptekst godkjenning", "resourceHeaderAuthSubmit": "Angi topptekst godkjenning",
"actionSetResourceHeaderAuth": "Angi topptekst godkjenning", "actionSetResourceHeaderAuth": "Angi topptekst godkjenning",
"enterpriseEdition": "Enterprise Edition", "enterpriseEdition": "Enterprise-utgave",
"unlicensed": "Ikke lisensiert", "unlicensed": "Ikke lisensiert",
"beta": "beta", "beta": "beta",
"manageUserDevices": "Bruker Enheter", "manageUserDevices": "Bruker Enheter",
@@ -3501,7 +3503,7 @@
"priority": "Prioritet", "priority": "Prioritet",
"priorityDescription": "Høyere prioriterte ruter evalueres først. Prioritet = 100 betyr automatisk bestilling (systembeslutninger). Bruk et annet nummer til å håndheve manuell prioritet.", "priorityDescription": "Høyere prioriterte ruter evalueres først. Prioritet = 100 betyr automatisk bestilling (systembeslutninger). Bruk et annet nummer til å håndheve manuell prioritet.",
"instanceName": "Forekomst navn", "instanceName": "Forekomst navn",
"clearInstanceName": "Reset Server Association", "clearInstanceName": "Tilbakestill server-assosiasjon",
"pathMatchModalTitle": "Konfigurere matching av sti", "pathMatchModalTitle": "Konfigurere matching av sti",
"pathMatchModalDescription": "Sett opp hvordan innkommende forespørsler skal matches basert på deres bane.", "pathMatchModalDescription": "Sett opp hvordan innkommende forespørsler skal matches basert på deres bane.",
"pathMatchType": "Trefftype", "pathMatchType": "Trefftype",
@@ -3557,11 +3559,11 @@
"allowedByRule": "Tillatt etter regel", "allowedByRule": "Tillatt etter regel",
"allowedNoAuth": "Tillatt Ingen Auth", "allowedNoAuth": "Tillatt Ingen Auth",
"validAccessToken": "Gyldig tilgangsnøkkel", "validAccessToken": "Gyldig tilgangsnøkkel",
"validHeaderAuth": "Valid header auth", "validHeaderAuth": "Gyldig header-autentisering",
"validPincode": "Gyldig PIN-kode", "validPincode": "Gyldig PIN-kode",
"validPassword": "Gyldig passord", "validPassword": "Gyldig passord",
"validEmail": "Valid email", "validEmail": "Gyldig e-post",
"validSSO": "Valid SSO", "validSSO": "Gyldig SSO",
"validVirtualAPIKey": "Gyldig Virtuell API-nøkkel", "validVirtualAPIKey": "Gyldig Virtuell API-nøkkel",
"view": "Vis", "view": "Vis",
"configManaged": "Konfigurasjon administrert", "configManaged": "Konfigurasjon administrert",
@@ -3570,7 +3572,7 @@
"droppedByRule": "Legg i regelen", "droppedByRule": "Legg i regelen",
"noSessions": "Ingen økter", "noSessions": "Ingen økter",
"temporaryRequestToken": "Midlertidig forespørsel Token", "temporaryRequestToken": "Midlertidig forespørsel Token",
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "Ingen gyldig autentisering",
"ip": "IP", "ip": "IP",
"reason": "Grunn", "reason": "Grunn",
"requestLogs": "HTTP-forespørselslogger", "requestLogs": "HTTP-forespørselslogger",
@@ -3784,14 +3786,14 @@
"niceIdUpdateErrorDescription": "Det oppstod en feil under oppdatering av Nice ID.", "niceIdUpdateErrorDescription": "Det oppstod en feil under oppdatering av Nice ID.",
"niceIdCannotBeEmpty": "God ID kan ikke være tom", "niceIdCannotBeEmpty": "God ID kan ikke være tom",
"enterIdentifier": "Angi identifikator", "enterIdentifier": "Angi identifikator",
"identifier": "Identifier", "identifier": "Identifikator",
"deviceLoginUseDifferentAccount": "Ikke du? Bruk en annen konto.", "deviceLoginUseDifferentAccount": "Ikke du? Bruk en annen konto.",
"deviceLoginDeviceRequestingAccessToAccount": "En enhet ber om tilgang til denne kontoen.", "deviceLoginDeviceRequestingAccessToAccount": "En enhet ber om tilgang til denne kontoen.",
"loginSelectAuthenticationMethod": "Velg en autentiseringsmetode for å fortsette.", "loginSelectAuthenticationMethod": "Velg en autentiseringsmetode for å fortsette.",
"noData": "Ingen data", "noData": "Ingen data",
"machineClients": "Maskinklienter", "machineClients": "Maskinklienter",
"install": "Installer", "install": "Installer",
"downloadInstaller": "Download Installer", "downloadInstaller": "Last ned installasjonsprogram",
"run": "Kjør", "run": "Kjør",
"envFile": "Miljøfil", "envFile": "Miljøfil",
"serviceFile": "Tjenestefil", "serviceFile": "Tjenestefil",
@@ -3969,7 +3971,7 @@
"kernelVersion": "Kjerne versjon", "kernelVersion": "Kjerne versjon",
"deviceModel": "Enhets modell", "deviceModel": "Enhets modell",
"serialNumber": "Serienummer", "serialNumber": "Serienummer",
"hostname": "Hostname", "hostname": "Vertsnavn",
"firstSeen": "Først sett", "firstSeen": "Først sett",
"lastSeen": "Sist sett", "lastSeen": "Sist sett",
"biometricsEnabled": "Biometri aktivert", "biometricsEnabled": "Biometri aktivert",
@@ -3999,7 +4001,7 @@
"disconnected": "Frakoblet", "disconnected": "Frakoblet",
"approvalsEmptyStateTitle": "Enhetsgodkjenninger er ikke aktivert", "approvalsEmptyStateTitle": "Enhetsgodkjenninger er ikke aktivert",
"approvalsEmptyStateDescription": "Aktivere godkjenninger av enheter for at roller må godkjennes av admin før brukere kan koble til nye enheter.", "approvalsEmptyStateDescription": "Aktivere godkjenninger av enheter for at roller må godkjennes av admin før brukere kan koble til nye enheter.",
"approvalsEmptyStateHowToTitle": "How to Enable", "approvalsEmptyStateHowToTitle": "Hvordan aktivere",
"approvalsEmptyStateStep1Title": "Gå til roller", "approvalsEmptyStateStep1Title": "Gå til roller",
"approvalsEmptyStateStep1Description": "Naviger til organisasjonens roller innstillinger for å konfigurere enhetsgodkjenninger.", "approvalsEmptyStateStep1Description": "Naviger til organisasjonens roller innstillinger for å konfigurere enhetsgodkjenninger.",
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger", "approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
+5 -3
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Geen sites gevonden.", "sitesNotFound": "Geen sites gevonden.",
"pangolinServerAdmin": "Serverbeheer - Pangolin", "pangolinServerAdmin": "Serverbeheer - Pangolin",
"licenseTierProfessional": "Professionele licentie", "licenseTierProfessional": "Professionele licentie",
"licenseTierEnterprise": "Enterprise Licentie", "licenseTierEnterprise": "Onderneming",
"licenseTierPersonal": "Persoonlijke licentie", "licenseTierPersonal": "Persoonlijk",
"licenseTierTier1": "Beginner",
"licenseTierTier2": "Schaal",
"licensed": "Gelicentieerd", "licensed": "Gelicentieerd",
"yes": "ja", "yes": "ja",
"no": "Neen", "no": "Neen",
@@ -3205,7 +3207,7 @@
"idpAzureClientIdDescription2": "Azure App registratie Client ID", "idpAzureClientIdDescription2": "Azure App registratie Client ID",
"idpAzureClientSecretDescription2": "Azure App registratie client geheim", "idpAzureClientSecretDescription2": "Azure App registratie client geheim",
"idpGoogleDescription": "Algemene OAuth2/OIDC provider", "idpGoogleDescription": "Algemene OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC-provider",
"subnet": "Subnet", "subnet": "Subnet",
"utilitySubnet": "Hulpmiddel Subnet", "utilitySubnet": "Hulpmiddel Subnet",
"subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.",
+6 -4
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Nie znaleziono witryn.", "sitesNotFound": "Nie znaleziono witryn.",
"pangolinServerAdmin": "Administrator serwera - Pangolin", "pangolinServerAdmin": "Administrator serwera - Pangolin",
"licenseTierProfessional": "Licencja Professional", "licenseTierProfessional": "Licencja Professional",
"licenseTierEnterprise": "Licencja Enterprise", "licenseTierEnterprise": "Przedsiębiorstwo",
"licenseTierPersonal": "Licencja osobista", "licenseTierPersonal": "Osobiste",
"licenseTierTier1": "Startowy",
"licenseTierTier2": "Skala",
"licensed": "Licencjonowany", "licensed": "Licencjonowany",
"yes": "Tak", "yes": "Tak",
"no": "Nie", "no": "Nie",
@@ -3791,7 +3793,7 @@
"noData": "Brak danych", "noData": "Brak danych",
"machineClients": "Klienci maszyn", "machineClients": "Klienci maszyn",
"install": "Zainstaluj", "install": "Zainstaluj",
"downloadInstaller": "Download Installer", "downloadInstaller": "Pobierz instalator",
"run": "Uruchom", "run": "Uruchom",
"envFile": "Plik środowiska", "envFile": "Plik środowiska",
"serviceFile": "Plik serwisu", "serviceFile": "Plik serwisu",
@@ -3999,7 +4001,7 @@
"disconnected": "Rozłączony", "disconnected": "Rozłączony",
"approvalsEmptyStateTitle": "Zatwierdzanie urządzenia nie włączone", "approvalsEmptyStateTitle": "Zatwierdzanie urządzenia nie włączone",
"approvalsEmptyStateDescription": "Włącz zatwierdzanie urządzeń dla ról aby wymagać zgody administratora, zanim użytkownicy będą mogli podłączyć nowe urządzenia.", "approvalsEmptyStateDescription": "Włącz zatwierdzanie urządzeń dla ról aby wymagać zgody administratora, zanim użytkownicy będą mogli podłączyć nowe urządzenia.",
"approvalsEmptyStateHowToTitle": "How to Enable", "approvalsEmptyStateHowToTitle": "Jak włączyć",
"approvalsEmptyStateStep1Title": "Przejdź do ról", "approvalsEmptyStateStep1Title": "Przejdź do ról",
"approvalsEmptyStateStep1Description": "Przejdź do ustawień ról swojej organizacji, aby skonfigurować zatwierdzenia urządzenia.", "approvalsEmptyStateStep1Description": "Przejdź do ustawień ról swojej organizacji, aby skonfigurować zatwierdzenia urządzenia.",
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia", "approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Nenhum site encontrado.", "sitesNotFound": "Nenhum site encontrado.",
"pangolinServerAdmin": "Administrador do Servidor - Pangolin", "pangolinServerAdmin": "Administrador do Servidor - Pangolin",
"licenseTierProfessional": "Licença Profissional", "licenseTierProfessional": "Licença Profissional",
"licenseTierEnterprise": "Licença Empresarial", "licenseTierEnterprise": "Empresa",
"licenseTierPersonal": "Licença Pessoal", "licenseTierPersonal": "Pessoal",
"licenseTierTier1": "Iniciante",
"licenseTierTier2": "Escala",
"licensed": "Licenciado", "licensed": "Licenciado",
"yes": "Sim", "yes": "Sim",
"no": "Não", "no": "Não",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Сайты не найдены.", "sitesNotFound": "Сайты не найдены.",
"pangolinServerAdmin": "Администратор сервера - Pangolin", "pangolinServerAdmin": "Администратор сервера - Pangolin",
"licenseTierProfessional": "Профессиональная лицензия", "licenseTierProfessional": "Профессиональная лицензия",
"licenseTierEnterprise": "Корпоративная лицензия", "licenseTierEnterprise": "Предприятие",
"licenseTierPersonal": "Личная лицензия", "licenseTierPersonal": "Личное",
"licenseTierTier1": "Старт",
"licenseTierTier2": "Масштаб",
"licensed": "Лицензировано", "licensed": "Лицензировано",
"yes": "Да", "yes": "Да",
"no": "Нет", "no": "Нет",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "Site bulunamadı.", "sitesNotFound": "Site bulunamadı.",
"pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin", "pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin",
"licenseTierProfessional": "Profesyonel Lisans", "licenseTierProfessional": "Profesyonel Lisans",
"licenseTierEnterprise": "Kurumsal Lisans", "licenseTierEnterprise": "Kurumsal",
"licenseTierPersonal": "Kişisel Lisans", "licenseTierPersonal": "Kişisel",
"licenseTierTier1": "Başlangıç",
"licenseTierTier2": "Ölçek",
"licensed": "Lisanslı", "licensed": "Lisanslı",
"yes": "Evet", "yes": "Evet",
"no": "Hayır", "no": "Hayır",
+4 -2
View File
@@ -1118,8 +1118,10 @@
"sitesNotFound": "未找到站点。", "sitesNotFound": "未找到站点。",
"pangolinServerAdmin": "服务器管理 - Pangolin", "pangolinServerAdmin": "服务器管理 - Pangolin",
"licenseTierProfessional": "专业许可证", "licenseTierProfessional": "专业许可证",
"licenseTierEnterprise": "企业许可证", "licenseTierEnterprise": "企业",
"licenseTierPersonal": "个人许可证", "licenseTierPersonal": "个人",
"licenseTierTier1": "启动器",
"licenseTierTier2": "扩展",
"licensed": "已授权", "licensed": "已授权",
"yes": "是", "yes": "是",
"no": "否", "no": "否",
+2661 -1914
View File
File diff suppressed because it is too large Load Diff
+29 -29
View File
@@ -33,13 +33,13 @@
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "9.1.0", "@asteasolutions/zod-to-openapi": "9.1.0",
"@aws-sdk/client-s3": "3.1121.0", "@aws-sdk/client-s3": "3.1131.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz", "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
"@headlessui/react": "2.2.10", "@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.2.0", "@node-rs/argon2": "2.2.1",
"@novnc/novnc": "^1.7.0", "@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
@@ -65,8 +65,8 @@
"@react-email/components": "1.0.12", "@react-email/components": "1.0.12",
"@react-email/render": "2.1.0", "@react-email/render": "2.1.0",
"@react-email/tailwind": "2.0.7", "@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "14.0.0",
"@simplewebauthn/server": "13.3.3", "@simplewebauthn/server": "14.0.1",
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.102.8", "@tanstack/react-query": "5.102.8",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
@@ -99,29 +99,29 @@
"js-yaml": "5.4.1", "js-yaml": "5.4.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lru-cache": "11.5.2", "lru-cache": "11.5.2",
"lucide-react": "1.38.0", "lucide-react": "1.45.0",
"maxmind": "5.0.7", "maxmind": "5.0.7",
"moment": "2.30.1", "moment": "2.30.1",
"next": "16.3.3", "next": "16.3.5",
"next-intl": "4.14.1", "next-intl": "4.14.4",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"nodemailer": "9.1.0", "nodemailer": "10.0.9",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.23.0", "pg": "8.23.0",
"posthog-node": "5.51.4", "posthog-node": "5.52.1",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.8", "react": "19.3.0",
"react-day-picker": "9.14.0", "react-day-picker": "10.0.1",
"react-dom": "19.2.8", "react-dom": "19.3.0",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.87.0", "react-hook-form": "7.88.0",
"react-icons": "5.7.0", "react-icons": "5.7.0",
"recharts": "3.10.1", "recharts": "3.10.1",
"reodotdev": "1.1.0", "reodotdev": "1.1.0",
"semver": "7.8.5", "semver": "7.8.5",
"sshpk": "1.18.0", "sshpk": "1.18.0",
"stripe": "22.6.0", "stripe": "22.6.2",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
@@ -133,15 +133,15 @@
"winston": "3.19.0", "winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
"ws": "8.21.3", "ws": "8.21.3",
"yaml": "2.9.0", "yaml": "2.9.1",
"yargs": "18.1.0", "yargs": "18.1.0",
"zod": "4.5.4", "zod": "4.6.2",
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "2.23.0", "@dotenvx/dotenvx": "2.24.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.9.3", "@react-email/ui": "^6.9.5",
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@tanstack/react-query-devtools": "5.102.8", "@tanstack/react-query-devtools": "5.102.8",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
@@ -155,12 +155,12 @@
"@types/jmespath": "0.15.2", "@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "9.0.10",
"@types/node": "26.4.0", "@types/node": "26.5.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.23.1", "@types/pg": "8.23.1",
"@types/react": "19.2.18", "@types/react": "19.3.0",
"@types/react-dom": "19.2.5", "@types/react-dom": "19.3.0",
"@types/semver": "7.8.0", "@types/semver": "7.8.0",
"@types/sshpk": "1.17.5", "@types/sshpk": "1.17.5",
"@types/swagger-ui-express": "4.1.8", "@types/swagger-ui-express": "4.1.8",
@@ -171,20 +171,20 @@
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"esbuild": "0.28.2", "esbuild": "0.28.2",
"esbuild-node-externals": "2.0.0", "esbuild-node-externals": "2.0.0",
"eslint": "10.9.1", "eslint": "10.10.0",
"eslint-config-next": "16.3.3", "eslint-config-next": "16.3.5",
"postcss": "8.5.26", "postcss": "8.5.28",
"prettier": "3.9.6", "prettier": "3.9.6",
"react-email": "6.9.3", "react-email": "6.9.5",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"tsc-alias": "1.9.2", "tsc-alias": "1.9.5",
"tsx": "4.23.13", "tsx": "4.23.13",
"typescript": "6.0.3", "typescript": "7.0.2",
"typescript-eslint": "8.68.0" "typescript-eslint": "8.70.0"
}, },
"overrides": { "overrides": {
"esbuild": "0.28.2", "esbuild": "0.28.2",
"dompurify": "3.4.0", "dompurify": "3.4.0",
"postcss": "8.5.26" "postcss": "8.5.28"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 713 KiB

+13 -9
View File
@@ -1,5 +1,6 @@
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { readConfigFile } from "@server/lib/readConfigFile"; import { readConfigFile } from "@server/lib/readConfigFile";
import { readEnvOrFile } from "@server/lib/getEnvOrYaml";
import { withReplicas } from "drizzle-orm/pg-core"; import { withReplicas } from "drizzle-orm/pg-core";
import { createPool } from "./poolConfig"; import { createPool } from "./poolConfig";
@@ -7,17 +8,20 @@ function createDb() {
const config = readConfigFile(); const config = readConfigFile();
// check the environment variables for postgres config first before the config file // check the environment variables for postgres config first before the config file
if (process.env.POSTGRES_CONNECTION_STRING) { const envConnectionString = readEnvOrFile("POSTGRES_CONNECTION_STRING");
if (envConnectionString) {
config.postgres = { config.postgres = {
connection_string: process.env.POSTGRES_CONNECTION_STRING connection_string: envConnectionString
}; };
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { const replicaConnectionStrings = readEnvOrFile(
const replicas = "POSTGRES_REPLICA_CONNECTION_STRINGS"
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map( );
(conn) => ({ if (replicaConnectionStrings) {
connection_string: conn.trim() const replicas = replicaConnectionStrings
}) .split(",")
); .map((conn) => ({
connection_string: conn.trim()
}));
config.postgres.replicas = replicas; config.postgres.replicas = replicas;
} }
} }
+11 -8
View File
@@ -1,5 +1,6 @@
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { readConfigFile } from "@server/lib/readConfigFile"; import { readConfigFile } from "@server/lib/readConfigFile";
import { readEnvOrFile } from "@server/lib/getEnvOrYaml";
import { withReplicas } from "drizzle-orm/pg-core"; import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build"; import { build } from "@server/build";
import { db as mainDb } from "./driver"; import { db as mainDb } from "./driver";
@@ -17,7 +18,7 @@ function createLogsDb() {
const logsConfig = config.postgres_logs; const logsConfig = config.postgres_logs;
// Check environment variable first // Check environment variable first
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING; let connectionString = readEnvOrFile("POSTGRES_LOGS_CONNECTION_STRING");
let replicaConnections: Array<{ connection_string: string }> = []; let replicaConnections: Array<{ connection_string: string }> = [];
if (!connectionString && logsConfig) { if (!connectionString && logsConfig) {
@@ -26,13 +27,15 @@ function createLogsDb() {
} }
// If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it // If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it
if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) { const replicaConnectionStrings = readEnvOrFile(
replicaConnections = "POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS"
process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map( );
(conn) => ({ if (replicaConnectionStrings) {
connection_string: conn.trim() replicaConnections = replicaConnectionStrings
}) .split(",")
); .map((conn) => ({
connection_string: conn.trim()
}));
} }
// If no logs database is configured, fall back to main database // If no logs database is configured, fall back to main database
+2 -1
View File
@@ -6,6 +6,7 @@ import fs from "fs";
import { APP_PATH } from "@server/lib/consts"; import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs"; import { existsSync, mkdirSync } from "fs";
import logger from "@server/logger"; import logger from "@server/logger";
import { readEnvOrFile } from "@server/lib/getEnvOrYaml";
export const location = path.join(APP_PATH, "db", "db.sqlite"); export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = checkFileExists(location); export const exists = checkFileExists(location);
@@ -19,7 +20,7 @@ function createDb() {
: undefined; : undefined;
const sqlite = new Database(location, { verbose }); const sqlite = new Database(location, { verbose });
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { if (readEnvOrFile("ENABLE_SQLITE_WAL_MODE") == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing // Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping). // contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this // NOTE: journal_mode persists in the DB file once set; unsetting this
@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
</EmailSection> </EmailSection>
<EmailText> <EmailText>
If you need to purchase additional license keys or For any questions or concerns regarding your license
modify your existing license, please reach out to or billing, please reach out to support at{" "}
our support team at{" "}
<a <a
href="mailto:support@pangolin.net" href="mailto:support@pangolin.net"
className="text-primary font-medium" className="text-primary font-medium"
+101
View File
@@ -0,0 +1,101 @@
import { formatBackupTimestamp } from "./backupFileName";
import { assertEquals } from "@test/assert";
// Local-time constructors are used throughout, matching formatBackupTimestamp,
// so these cases do not depend on the machine's timezone.
function testMonthIsOneIndexed() {
console.log("Running month indexing tests...");
// The case from the report: a backup taken on 12 September 2026 was named
// db_2026-8-12_... because Date#getMonth is zero-indexed.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 35, 56));
assertEquals(
result,
"2026-09-12_20-35-56",
"September must render as 09, not 8"
);
}
// The other reported name, db_2026-0-23_..., was a January backup.
{
const result = formatBackupTimestamp(new Date(2026, 0, 23, 20, 25, 49));
assertEquals(
result,
"2026-01-23_20-25-49",
"January must render as 01, not 0"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
assertEquals(
result,
"2026-12-31_23-59-59",
"December must render as 12"
);
}
}
function testEveryFieldIsZeroPadded() {
console.log("Running zero padding tests...");
// db_2026-8-12_20-36-2 in the report: a single-digit second was not padded.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 36, 2));
assertEquals(
result,
"2026-09-12_20-36-02",
"Single-digit seconds must be padded"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 0, 1, 0, 0, 0));
assertEquals(
result,
"2026-01-01_00-00-00",
"Midnight on the first of the month must pad every field"
);
}
}
function testNamesSortChronologically() {
console.log("Running sort order tests...");
// Zero padding means a plain lexicographic sort of the backups directory
// lists the backups in the order they were taken.
const taken = [
new Date(2026, 8, 12, 20, 36, 2),
new Date(2026, 0, 23, 20, 25, 49),
new Date(2026, 8, 12, 20, 35, 56),
new Date(2026, 11, 31, 23, 59, 59)
];
const sorted = taken
.map((date) => formatBackupTimestamp(date))
.sort();
assertEquals(
sorted.join(","),
[
"2026-01-23_20-25-49",
"2026-09-12_20-35-56",
"2026-09-12_20-36-02",
"2026-12-31_23-59-59"
].join(","),
"Backup names must sort into the order the backups were taken"
);
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Builds the timestamp segment of a database backup file name.
*
* `Date#getMonth` is zero-indexed, so building this inline produced names like
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
* also zero-padded, which keeps the names unambiguous and makes them sort
* lexicographically in the order they were taken.
*
* @param date The moment the backup is being taken. Defaults to now.
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
*/
export function formatBackupTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
const datePart = [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("-");
const timePart = [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join("-");
return `${datePart}_${timePart}`;
}
+6 -6
View File
@@ -1,6 +1,6 @@
export enum LicenseId { export enum LicenseId {
SMALL_LICENSE = "small_license", TIER1 = "tier1",
BIG_LICENSE = "big_license" TIER2 = "tier2"
} }
export type LicensePriceSet = { export type LicensePriceSet = {
@@ -9,15 +9,15 @@ export type LicensePriceSet = {
export const licensePriceSet: LicensePriceSet = { export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet // Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT", [LicenseId.TIER1]: "price_1TMJzmD3Ee2Ir7Wm05NlGImT",
[LicenseId.BIG_LICENSE]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS" [LicenseId.TIER2]: "price_1TMJzzD3Ee2Ir7WmzJw9TerS"
}; };
export const licensePriceSetSandbox: LicensePriceSet = { export const licensePriceSetSandbox: LicensePriceSet = {
// Free license matches the freeLimitSet // Free license matches the freeLimitSet
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value // when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", [LicenseId.TIER1]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" [LicenseId.TIER2]: "price_1SxDy0DCpkOb237BWJxrxYkl"
}; };
export function getLicensePriceSet( export function getLicensePriceSet(
+36 -2
View File
@@ -1,3 +1,37 @@
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { import fs from "fs";
return process.env[envVar] ?? valFromYaml;
// Resolves an environment variable, also honoring a `<envVar>_FILE` variant
// that points to a file whose (trimmed) contents should be used as the
// value. This is the common convention for consuming Docker/Swarm secrets
// (e.g. mounted at /run/secrets/...) without putting the raw value in the
// container's environment.
export const readEnvOrFile = (envVar: string): string | undefined => {
const fileEnvVar = `${envVar}_FILE`;
const filePath = process.env[fileEnvVar];
if (filePath) {
if (process.env[envVar]) {
throw new Error(
`Both ${envVar} and ${fileEnvVar} are set. Please set only one.`
);
}
try {
return fs.readFileSync(filePath, "utf8").trim();
} catch (error) {
throw new Error(
`Failed to read ${fileEnvVar} (${filePath}): ${
error instanceof Error ? error.message : error
}`
);
}
}
return process.env[envVar];
}; };
export const getEnvOrYaml =
(envVar: string) =>
(valFromYaml: string | undefined): string | undefined => {
return readEnvOrFile(envVar) ?? valFromYaml;
};
+15 -28
View File
@@ -3,7 +3,7 @@ import * as yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts"; import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod"; import { z } from "zod";
import stoi from "./stoi"; import stoi from "./stoi";
import { getEnvOrYaml } from "./getEnvOrYaml"; import { getEnvOrYaml, readEnvOrFile } from "./getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
@@ -26,14 +26,12 @@ export const configSchema = z
.object({ .object({
anonymous_usage: z.boolean().optional().default(true) anonymous_usage: z.boolean().optional().default(true)
}) })
.optional()
.prefault({}), .prefault({}),
notifications: z notifications: z
.object({ .object({
product_updates: z.boolean().optional().default(true), product_updates: z.boolean().optional().default(true),
new_releases: z.boolean().optional().default(true) new_releases: z.boolean().optional().default(true)
}) })
.optional()
.prefault({}) .prefault({})
}) })
.optional() .optional()
@@ -109,7 +107,6 @@ export const configSchema = z
id: z.string().optional().default("P-Access-Token-Id"), id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token") token: z.string().optional().default("P-Access-Token")
}) })
.optional()
.prefault({}), .prefault({}),
remote_headers: z remote_headers: z
.object({ .object({
@@ -126,7 +123,6 @@ export const configSchema = z
name: z.string().optional().default("Remote-Name"), name: z.string().optional().default("Remote-Name"),
role: z.string().optional().default("Remote-Role") role: z.string().optional().default("Remote-Role")
}) })
.optional()
.prefault({}), .prefault({}),
resource_session_request_param: z resource_session_request_param: z
.string() .string()
@@ -164,14 +160,17 @@ export const configSchema = z
.boolean() .boolean()
.optional() .optional()
.default(false) .default(false)
.transform((val) => .transform((val) => {
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !== const envVal = readEnvOrFile(
undefined "ENABLE_AI_GATEWAY_CLIENT_IP_HEADER"
? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER === );
"true" return envVal !== undefined ? envVal === "true" : val;
: val }),
), secret: z
secret: z.string().pipe(z.string().min(8)).optional(), .string()
.pipe(z.string().min(8))
.optional()
.transform(getEnvOrYaml("SERVER_SECRET")),
maxmind_db_path: z.string().optional(), maxmind_db_path: z.string().optional(),
maxmind_asn_path: z.string().optional() maxmind_asn_path: z.string().optional()
}) })
@@ -202,7 +201,8 @@ export const configSchema = z
dashboard_session_length_hours: 720, dashboard_session_length_hours: 720,
resource_session_length_hours: 720, resource_session_length_hours: 720,
trust_proxy: 1, trust_proxy: 1,
enable_ai_gateway_client_ip_header: false enable_ai_gateway_client_ip_header: false,
secret: undefined
}), }),
postgres: z postgres: z
.object({ .object({
@@ -238,7 +238,6 @@ export const configSchema = z
.default(5000), .default(5000),
jit_mode: z.boolean().default(true) jit_mode: z.boolean().default(true)
}) })
.optional()
.prefault({}) .prefault({})
}) })
.optional(), .optional(),
@@ -278,7 +277,6 @@ export const configSchema = z
.optional() .optional()
.default(5000) .default(5000)
}) })
.optional()
.prefault({}) .prefault({})
}) })
.optional(), .optional(),
@@ -325,10 +323,8 @@ export const configSchema = z
.optional() .optional()
.default(50) .default(50)
}) })
.optional()
.prefault({}) .prefault({})
}) })
.optional()
.prefault({}), .prefault({}),
gerbil: z gerbil: z
.object({ .object({
@@ -357,7 +353,6 @@ export const configSchema = z
.optional() .optional()
.default(30) .default(30)
}) })
.optional()
.prefault({}), .prefault({}),
orgs: z orgs: z
.object({ .object({
@@ -391,7 +386,6 @@ export const configSchema = z
.optional() .optional()
.default(500) .default(500)
}) })
.optional()
.prefault({}), .prefault({}),
auth: z auth: z
.object({ .object({
@@ -408,10 +402,8 @@ export const configSchema = z
.optional() .optional()
.default(500) .default(500)
}) })
.optional()
.prefault({}) .prefault({})
}) })
.optional()
.prefault({}), .prefault({}),
email: z email: z
.object({ .object({
@@ -489,10 +481,8 @@ export const configSchema = z
.optional() .optional()
.default(12) .default(12)
}) })
.optional()
.prefault({}) .prefault({})
}) })
.optional()
.prefault({}) .prefault({})
}) })
.refine( .refine(
@@ -513,10 +503,7 @@ export const configSchema = z
) )
.refine( .refine(
(data) => { (data) => {
// If hybrid is not defined, server secret must be defined. If its not defined already then pull it from env // If hybrid is not defined, server secret must be defined
if (data.server?.secret === undefined) {
data.server.secret = process.env.SERVER_SECRET;
}
return ( return (
data.server?.secret !== undefined && data.server?.secret !== undefined &&
data.server.secret.length > 0 data.server.secret.length > 0
+53 -24
View File
@@ -14,6 +14,7 @@ import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "@server/lib/certificates"; import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build"; import { build } from "@server/build";
import license from "#dynamic/license/license";
export class TraefikConfigManager { export class TraefikConfigManager {
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
@@ -357,7 +358,11 @@ export class TraefikConfigManager {
this.lastActiveDomains = new Set(domains); this.lastActiveDomains = new Set(domains);
} }
if (process.env.CERT_MODE === "pangolin" && build != "oss") { if (
process.env.CERT_MODE === "pangolin" &&
build != "oss" &&
(await license.hasTier(["personal", "tier2", "enterprise"]))
) {
// Scan current local certificate state // Scan current local certificate state
this.lastLocalCertificateState = this.lastLocalCertificateState =
await this.scanLocalCertificateState(); await this.scanLocalCertificateState();
@@ -717,10 +722,9 @@ export class TraefikConfigManager {
} }
if (shouldWrite) { if (shouldWrite) {
try { try {
fs.writeFileSync( this.atomicWriteFileSync(
traefikDynamicConfigPath, traefikDynamicConfigPath,
yaml.dump(traefikConfig, { noRefs: true }), yaml.dump(traefikConfig, { noRefs: true })
"utf8"
); );
logger.info("Traefik dynamic config updated"); logger.info("Traefik dynamic config updated");
} catch (err) { } catch (err) {
@@ -822,7 +826,7 @@ export class TraefikConfigManager {
// Only write the config if it has changed // Only write the config if it has changed
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
if (newConfigYaml !== originalConfigYaml) { if (newConfigYaml !== originalConfigYaml) {
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml);
logger.info("Dynamic cert config updated from local certificates"); logger.info("Dynamic cert config updated from local certificates");
} }
} }
@@ -900,26 +904,23 @@ export class TraefikConfigManager {
`Processing certificate for domain: ${cert.domain}` `Processing certificate for domain: ${cert.domain}`
); );
fs.writeFileSync(certPath, cert.certFile, "utf8"); // Write atomically (temp file + rename) so Traefik's
fs.writeFileSync(keyPath, cert.keyFile, "utf8"); // file watcher never observes a partially written
// cert/key and fails with "failed to find any PEM data".
// Set appropriate permissions (readable by owner only for key file) this.atomicWriteFileSync(certPath, cert.certFile, 0o644);
fs.chmodSync(certPath, 0o644); this.atomicWriteFileSync(keyPath, cert.keyFile, 0o600);
fs.chmodSync(keyPath, 0o600);
// Write/update .last_update file with current timestamp // Write/update .last_update file with current timestamp
fs.writeFileSync( this.atomicWriteFileSync(
lastUpdatePath, lastUpdatePath,
new Date().toISOString(), new Date().toISOString()
"utf8"
); );
// Check if this is a wildcard certificate and store it // Check if this is a wildcard certificate and store it
const wildcardPath = path.join(domainDir, ".wildcard"); const wildcardPath = path.join(domainDir, ".wildcard");
fs.writeFileSync( this.atomicWriteFileSync(
wildcardPath, wildcardPath,
cert.wildcard ? "true" : "false", cert.wildcard ? "true" : "false"
"utf8"
); );
logger.info( logger.info(
@@ -931,10 +932,9 @@ export class TraefikConfigManager {
// even if the cert content didn't change // even if the cert content didn't change
if (cert.expiresAt) { if (cert.expiresAt) {
const expiresAtPath = path.join(domainDir, ".expires_at"); const expiresAtPath = path.join(domainDir, ".expires_at");
fs.writeFileSync( this.atomicWriteFileSync(
expiresAtPath, expiresAtPath,
cert.expiresAt.toString(), cert.expiresAt.toString()
"utf8"
); );
} }
@@ -970,7 +970,7 @@ export class TraefikConfigManager {
// Only write the config if it has changed // Only write the config if it has changed
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
if (newConfigYaml !== originalConfigYaml) { if (newConfigYaml !== originalConfigYaml) {
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml);
logger.info("Dynamic cert config updated"); logger.info("Dynamic cert config updated");
} }
} }
@@ -1141,10 +1141,9 @@ export class TraefikConfigManager {
if (configChanged) { if (configChanged) {
try { try {
fs.writeFileSync( this.atomicWriteFileSync(
dynamicConfigPath, dynamicConfigPath,
yaml.dump(dynamicConfig, { noRefs: true }), yaml.dump(dynamicConfig, { noRefs: true })
"utf8"
); );
logger.info("Dynamic config updated after cleanup"); logger.info("Dynamic config updated after cleanup");
} catch (err) { } catch (err) {
@@ -1171,6 +1170,36 @@ export class TraefikConfigManager {
} }
} }
/**
* Write a file atomically by writing to a temp file in the same
* directory and renaming it into place. This avoids Traefik (which
* watches these files/directories) picking up a partially written
* file and failing to parse it (e.g. "failed to find any PEM data").
*/
private atomicWriteFileSync(
filePath: string,
data: string,
mode?: number
): void {
const dir = path.dirname(filePath);
const tmpPath = path.join(
dir,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
try {
fs.writeFileSync(tmpPath, data, "utf8");
if (mode !== undefined) {
fs.chmodSync(tmpPath, mode);
}
fs.renameSync(tmpPath, filePath);
} catch (error) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {}
throw error;
}
}
/** /**
* Check if file exists * Check if file exists
*/ */
+6 -2
View File
@@ -4,7 +4,7 @@ import { setHostMeta } from "@server/lib/hostMeta";
const keyTypes = ["host"] as const; const keyTypes = ["host"] as const;
export type LicenseKeyType = (typeof keyTypes)[number]; export type LicenseKeyType = (typeof keyTypes)[number];
const keyTiers = ["personal", "enterprise"] as const; const keyTiers = ["personal", "enterprise", "tier1", "tier2"] as const;
export type LicenseKeyTier = (typeof keyTiers)[number]; export type LicenseKeyTier = (typeof keyTiers)[number];
export type LicenseStatus = { export type LicenseStatus = {
@@ -33,7 +33,7 @@ export type LicenseKeyCache = {
export class License { export class License {
private serverSecret!: string; private serverSecret!: string;
constructor(private hostMeta: HostMeta) { } constructor(private hostMeta: HostMeta) {}
public async check(): Promise<LicenseStatus> { public async check(): Promise<LicenseStatus> {
return { return {
@@ -50,6 +50,10 @@ export class License {
public async isUnlocked() { public async isUnlocked() {
return false; return false;
} }
public async hasTier(tier: LicenseKeyTier[]) {
return false;
}
} }
await setHostMeta(); await setHostMeta();
+2 -2
View File
@@ -45,9 +45,9 @@ export class JobScheduler {
label: string label: string
): () => Promise<void> { ): () => Promise<void> {
return async () => { return async () => {
if (!(await license.isUnlocked())) { if (!(await license.hasTier(["personal", "tier2", "enterprise"]))) {
logger.debug( logger.debug(
`Skipping ${label} tick - license is not subscribed` `Skipping ${label} tick - requires a tier2 license`
); );
return; return;
} }
+11 -6
View File
@@ -63,10 +63,11 @@ export class AuthoritativeDNSServer {
private allDomains: Set<string> = new Set(); private allDomains: Set<string> = new Set();
private domainRefreshInterval: NodeJS.Timeout | null = null; private domainRefreshInterval: NodeJS.Timeout | null = null;
// Cached license/subscription status. license.isUnlocked() does a DB // Cached license/plan status - only a tier2 license unlocks the DNS
// round-trip on every call, so it can't be checked per-query on a UDP // server. license.hasPlan() does a DB round-trip on every call, so it
// server that may see very high query volume - instead it's polled on // can't be checked per-query on a UDP server that may see very high
// the same cadence as the domain set refresh and read from memory here. // query volume - instead it's polled on the same cadence as the domain
// set refresh and read from memory here.
private isLicensed: boolean = false; private isLicensed: boolean = false;
private licenseRefreshInterval: NodeJS.Timeout | null = null; private licenseRefreshInterval: NodeJS.Timeout | null = null;
@@ -128,7 +129,7 @@ export class AuthoritativeDNSServer {
} }
if (!this.isLicensed) { if (!this.isLicensed) {
logger.debug("Refusing DNS query - license is not subscribed"); logger.debug("Refusing DNS query - requires a tier2 license");
// REFUSED (rcode=5) indicates a policy refusal by this nameserver. // REFUSED (rcode=5) indicates a policy refusal by this nameserver.
this.sendResponse(packet, [], rinfo, false, 5, []); this.sendResponse(packet, [], rinfo, false, 5, []);
return; return;
@@ -1042,7 +1043,11 @@ export class AuthoritativeDNSServer {
private async refreshLicenseStatus(): Promise<void> { private async refreshLicenseStatus(): Promise<void> {
try { try {
this.isLicensed = await license.isUnlocked(); this.isLicensed = await license.hasTier([
"personal",
"tier2",
"enterprise"
]);
} catch (error) { } catch (error) {
logger.error("Failed to refresh license status:", error); logger.error("Failed to refresh license status:", error);
this.isLicensed = false; this.isLicensed = false;
-3
View File
@@ -48,7 +48,6 @@ export const privateConfigSchema = z
.optional() .optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY")) .transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
}) })
.optional()
.prefault({}), .prefault({}),
redis: z redis: z
.object({ .object({
@@ -166,7 +165,6 @@ export const privateConfigSchema = z
.optional() .optional()
.default("http://gerbil:3004") .default("http://gerbil:3004")
}) })
.optional()
.prefault({}), .prefault({}),
flags: z flags: z
.object({ .object({
@@ -187,7 +185,6 @@ export const privateConfigSchema = z
// (server/private/lib/config.ts). // (server/private/lib/config.ts).
disable_private_http_placeholder: z.boolean().optional() disable_private_http_placeholder: z.boolean().optional()
}) })
.optional()
.prefault({}), .prefault({}),
acme: z acme: z
.object({ .object({
+40 -51
View File
@@ -54,6 +54,7 @@ import {
getValidCertificatesForDomains getValidCertificatesForDomains
} from "@server/lib/certificates"; } from "@server/lib/certificates";
import { build } from "@server/build"; import { build } from "@server/build";
import license from "#private/license/license";
import regionalCache from "#private/lib/cache"; import regionalCache from "#private/lib/cache";
import { TargetWithSite } from "@server/lib/traefik/types"; import { TargetWithSite } from "@server/lib/traefik/types";
import { buildWildcardTls } from "@server/lib/traefik/certResolver"; import { buildWildcardTls } from "@server/lib/traefik/certResolver";
@@ -395,8 +396,15 @@ export async function getTraefikConfig(
) )
); );
// Pangolin-managed DNS-01/ACME cert mode requires either a tier1
// license (self-hosted) or a saas build - otherwise fall back to
// Traefik's own cert resolvers (buildWildcardTls) throughout.
const pangolinCertModeEnabled =
privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin" &&
(await license.hasTier(["personal", "tier2", "enterprise"]));
let validCerts: CertificateResult[] = []; let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") { if (pangolinCertModeEnabled) {
// create a list of all domains to get certs for // create a list of all domains to get certs for
const domains = new Set<string>(); const domains = new Set<string>();
for (const resource of resourcesMap.values()) { for (const resource of resourcesMap.values()) {
@@ -522,10 +530,7 @@ export async function getTraefikConfig(
); );
let tls = {}; let tls = {};
if ( if (!pangolinCertModeEnabled) {
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
tls = buildWildcardTls({ tls = buildWildcardTls({
fullDomain, fullDomain,
hasSubdomain: !!resource.subdomain, hasSubdomain: !!resource.subdomain,
@@ -546,6 +551,30 @@ export async function getTraefikConfig(
} }
} }
config_output.http.services![serviceName] = {
loadBalancer: {
servers: buildHttpLoadBalancerServers(targets),
...(resource.stickySession
? buildStickySessionCookie(resource.ssl)
: {})
}
};
if (resource.tlsServerName) {
if (!config_output.http.serversTransports) {
config_output.http.serversTransports = {};
}
config_output.http.serversTransports![transportName] = {
serverName: resource.tlsServerName,
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
// if defined in the static config and here. if not set, self-signed certs won't work
insecureSkipVerify: true
};
config_output.http.services![
serviceName
].loadBalancer.serversTransport = transportName;
}
if (resource.ssl) { if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = { config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [ entryPoints: [
@@ -710,31 +739,6 @@ export async function getTraefikConfig(
priority: priority, priority: priority,
...(resource.ssl ? { tls } : {}) ...(resource.ssl ? { tls } : {})
}; };
config_output.http.services![serviceName] = {
loadBalancer: {
servers: buildHttpLoadBalancerServers(targets),
...(resource.stickySession
? buildStickySessionCookie(resource.ssl)
: {})
}
};
// Add the serversTransport if TLS server name is provided
if (resource.tlsServerName) {
if (!config_output.http.serversTransports) {
config_output.http.serversTransports = {};
}
config_output.http.serversTransports![transportName] = {
serverName: resource.tlsServerName,
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
// if defined in the static config and here. if not set, self-signed certs won't work
insecureSkipVerify: true
};
config_output.http.services![
serviceName
].loadBalancer.serversTransport = transportName;
}
} else if (resource.mode == "tcp" || resource.mode == "udp") { } else if (resource.mode == "tcp" || resource.mode == "udp") {
// Non-HTTP (TCP/UDP) configuration // Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy) { if (!resource.enableProxy) {
@@ -791,10 +795,7 @@ export async function getTraefikConfig(
domainCertResolver, domainCertResolver,
preferWildcardCert preferWildcardCert
}) => { }) => {
if ( if (!pangolinCertModeEnabled) {
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
return buildWildcardTls({ return buildWildcardTls({
fullDomain, fullDomain,
hasSubdomain, hasSubdomain,
@@ -835,10 +836,7 @@ export async function getTraefikConfig(
maintenancePageUiUrl, maintenancePageUiUrl,
redirectHttpsMiddlewareName, redirectHttpsMiddlewareName,
resolveTls: (fullDomain) => { resolveTls: (fullDomain) => {
if ( if (!pangolinCertModeEnabled) {
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
// siteResource aliases don't have a per-domain cert // siteResource aliases don't have a per-domain cert
// resolver stored, so always fall back to the global // resolver stored, so always fall back to the global
// defaults. // defaults.
@@ -929,10 +927,7 @@ export async function getTraefikConfig(
const rule = buildHostRule(fullDomain, ir.wildcard); const rule = buildHostRule(fullDomain, ir.wildcard);
let tls: any = {}; let tls: any = {};
if ( if (!pangolinCertModeEnabled) {
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
tls = buildWildcardTls({ tls = buildWildcardTls({
fullDomain, fullDomain,
hasSubdomain: !!ir.subdomain, hasSubdomain: !!ir.subdomain,
@@ -1012,10 +1007,7 @@ export async function getTraefikConfig(
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
let tls: any = {}; let tls: any = {};
if ( if (!pangolinCertModeEnabled) {
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
// siteResource aliases don't have a per-domain cert // siteResource aliases don't have a per-domain cert
// resolver stored, so always fall back to the global // resolver stored, so always fall back to the global
// defaults. // defaults.
@@ -1089,7 +1081,7 @@ export async function getTraefikConfig(
.where(eq(exitNodes.exitNodeId, exitNodeId)); .where(eq(exitNodes.exitNodeId, exitNodeId));
let validCertsLoginPages: CertificateResult[] = []; let validCertsLoginPages: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") { if (pangolinCertModeEnabled) {
// create a list of all domains to get certs for // create a list of all domains to get certs for
const domains = new Set<string>(); const domains = new Set<string>();
for (const lp of exitNodeLoginPages) { for (const lp of exitNodeLoginPages) {
@@ -1134,10 +1126,7 @@ export async function getTraefikConfig(
} }
const tls = {}; const tls = {};
if ( if (!pangolinCertModeEnabled) {
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
// TODO: we need to add the wildcard logic here too // TODO: we need to add the wildcard logic here too
} else { } else {
// find a cert that matches the full domain, if not continue // find a cert that matches the full domain, if not continue
+19 -2
View File
@@ -26,6 +26,7 @@ import {
LicenseStatus LicenseStatus
} from "@server/license/license"; } from "@server/license/license";
import { setHostMeta } from "@server/lib/hostMeta"; import { setHostMeta } from "@server/lib/hostMeta";
import { build } from "@server/build";
type ActivateLicenseKeyAPIResponse = { type ActivateLicenseKeyAPIResponse = {
data: { data: {
@@ -119,6 +120,9 @@ LQIDAQAB
} }
public async isUnlocked(): Promise<boolean> { public async isUnlocked(): Promise<boolean> {
if (build == "saas") {
return true;
}
const status = await this.check(); const status = await this.check();
if (status.isHostLicensed) { if (status.isHostLicensed) {
if (status.isLicenseValid) { if (status.isLicenseValid) {
@@ -128,6 +132,20 @@ LQIDAQAB
return false; return false;
} }
public async hasTier(tier: LicenseKeyTier[]): Promise<boolean> {
if (build == "saas") {
return true;
}
const status = await this.check();
if (status.isHostLicensed && status.isLicenseValid) {
return (
status.tier !== undefined &&
tier.includes(status.tier as LicenseKeyTier)
);
}
return false;
}
public async check(): Promise<LicenseStatus> { public async check(): Promise<LicenseStatus> {
// If a check is already in progress, return the last known status // If a check is already in progress, return the last known status
if (this.checkInProgress) { if (this.checkInProgress) {
@@ -135,8 +153,7 @@ LQIDAQAB
"License check already in progress, returning last known status" "License check already in progress, returning last known status"
); );
const lastStatus = this.statusCache.get(this.statusKey) as const lastStatus = this.statusCache.get(this.statusKey) as
| LicenseStatus LicenseStatus | undefined;
| undefined;
if (lastStatus) { if (lastStatus) {
return lastStatus; return lastStatus;
} }
@@ -54,7 +54,6 @@ export const queryAccessAuditLogsQuery = z.object({
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -47,7 +47,6 @@ export const queryActionAuditLogsQuery = z.object({
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -55,7 +55,6 @@ export const queryConnectionAuditLogsQuery = z.object({
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -222,15 +222,16 @@ export async function handleSubscriptionCreated(
let numUsers: number; let numUsers: number;
let numSites: number; let numSites: number;
let tier = "enterprise";
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) { if (subscriptionPriceId === priceSet[LicenseId.TIER1]) {
numUsers = 25; numUsers = 25;
numSites = 25; numSites = 25;
} else if ( tier = "tier1";
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] } else if (subscriptionPriceId === priceSet[LicenseId.TIER2]) {
) {
numUsers = 50; numUsers = 50;
numSites = 100; numSites = 100;
tier = "tier2";
} else { } else {
logger.error( logger.error(
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
@@ -242,6 +243,14 @@ export async function handleSubscriptionCreated(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
); );
// Grace period of 5 days added on top of the current billing
// period end (usually ~1 year out) before the license expires
const currentPeriodEnd =
fullSubscription.items.data[0]?.current_period_end;
const expiresAt =
(currentPeriodEnd ?? subscription.created) +
5 * 24 * 60 * 60;
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{ {
@@ -256,7 +265,9 @@ export async function handleSubscriptionCreated(
licenseId: parseInt(licenseId), licenseId: parseInt(licenseId),
paidFor: true, paidFor: true,
users: numUsers, users: numUsers,
sites: numSites sites: numSites,
tier: tier,
expiresAt: expiresAt
}) })
} }
); );
@@ -265,6 +276,13 @@ export async function handleSubscriptionCreated(
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`); logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (!response.ok || !data.success) {
logger.error(
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
);
return;
}
if (customer.email) { if (customer.email) {
logger.debug( logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}` `Sending license key email to ${customer.email} for subscription ${subscription.id}`
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` `Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
); );
try { try {
// WARNING: const invalidateResponse = await fetch(
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{ {
method: "POST", method: "POST",
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
}, },
body: JSON.stringify({ body: JSON.stringify({
orgId: customer.orgId, orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
}) })
} }
); );
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
@@ -23,7 +23,10 @@ import {
} from "@server/db"; } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import {
getFeatureIdByMetricId,
getFeatureIdByPriceId
} from "@server/lib/billing/features";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType, SubscriptionType } from "./getSubType"; import { getSubType, SubscriptionType } from "./getSubType";
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
.limit(1); .limit(1);
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null; const previousType =
existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the // If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it. // status down so Stripe webhooks can no longer change it.
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
logger.info( logger.info(
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}` `Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
); );
await handleTierChange(customer.orgId, type, previousType ?? undefined); await handleTierChange(
customer.orgId,
type,
previousType ?? undefined
);
} }
// Upsert subscription items // Upsert subscription items
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
const itemsToUpsert = fullSubscription.items.data.map((item) => { const itemsToUpsert = fullSubscription.items.data.map((item) => {
// Try to get featureId from price // Try to get featureId from price
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null; let featureId: string | null =
getFeatureIdByPriceId(item.price.id) || null;
// If no match, try to preserve existing featureId // If no match, try to preserve existing featureId
if (!featureId) { if (!featureId) {
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
logger.info( logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features` `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
); );
await handleTierChange(customer.orgId, null, previousType ?? undefined); await handleTierChange(
customer.orgId,
null,
previousType ?? undefined
);
} }
} else if (type === "license") { } else if (type === "license") {
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") { if (
effectiveStatus === "canceled" ||
effectiveStatus == "unpaid" ||
effectiveStatus == "incomplete_expired"
) {
try { try {
// WARNING: const invalidateResponse = await fetch(
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{ {
method: "POST", method: "POST",
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ body: JSON.stringify({
orgId: customer.orgId orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
}) })
} }
); );
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error error
); );
} }
} else if (effectiveStatus === "active" && previousAttributes) {
// Detect a successful renewal: the billing period rolled
// forward (the invoice was paid and the new period began
// right where the previous one ended).
const currentItem = fullSubscription.items.data[0];
const prevItems = previousAttributes.items?.data;
const prevItem = Array.isArray(prevItems)
? prevItems.find(
(pi: any) => pi.id === currentItem?.id
)
: undefined;
const renewed =
currentItem &&
prevItem?.current_period_end &&
currentItem.current_period_start ===
prevItem.current_period_end &&
currentItem.current_period_start >
prevItem.current_period_start;
if (renewed) {
const licenseKeyId =
subscription.metadata.licenseKeyId;
if (!licenseKeyId) {
logger.error(
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
);
} else {
// Grace period of 5 days added on top of the new
// billing period end (usually ~1 year out)
const expiresAt =
currentItem.current_period_end +
5 * 24 * 60 * 60;
try {
const extendResponse = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseKeyId),
expiresAt: expiresAt
})
}
);
if (!extendResponse.ok) {
logger.error(
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
);
} else {
logger.info(
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
error
);
}
}
}
} }
} }
} }
@@ -64,14 +64,11 @@ export async function generateNewEnterpriseLicense(
const licenseData = req.body; const licenseData = req.body;
if ( if (licenseData.tier != "tier2" && licenseData.tier != "tier1") {
licenseData.tier != "big_license" &&
licenseData.tier != "small_license"
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Invalid tier specified. Must be either 'big_license' or 'small_license'." "Invalid tier specified. Must be either 'tier2' or 'tier1'."
) )
); );
} }
@@ -99,6 +96,8 @@ export async function generateNewEnterpriseLicense(
); );
} }
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
// check if we already have a customer for this org // check if we already have a customer for this org
const [customer] = await db const [customer] = await db
.select() .select()
@@ -118,9 +117,7 @@ export async function generateNewEnterpriseLicense(
} }
const tier = const tier =
licenseData.tier === "big_license" licenseData.tier === "tier2" ? LicenseId.TIER2 : LicenseId.TIER1;
? LicenseId.BIG_LICENSE
: LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier]; const tierPrice = getLicensePriceSet()[tier];
const session = await stripe!.checkout.sessions.create({ const session = await stripe!.checkout.sessions.create({
@@ -134,6 +131,16 @@ export async function generateNewEnterpriseLicense(
], // Start with the standard feature set that matches the free limits ], // Start with the standard feature set that matches the free limits
customer: customer.customerId, customer: customer.customerId,
mode: "subscription", mode: "subscription",
subscription_data: {
description: licenseKeyValue
? `License ${licenseKeyValue}`
: `License key ID ${keyId}`,
metadata: {
licenseKeyId: keyId.toString(),
licenseKey: licenseKeyValue ?? "",
tier: licenseData.tier
}
},
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
@@ -47,7 +47,6 @@ export const queryAiSessionLogsQuery = z.strictObject({
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -20,7 +20,6 @@ const queryAccessAuditLogsQuery = z.object({
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => getSevenDaysAgo().toISOString()) .prefault(() => getSevenDaysAgo().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -34,7 +33,6 @@ const queryAccessAuditLogsQuery = z.object({
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -41,7 +41,6 @@ export const queryAccessAuditLogsQuery = z.strictObject({
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
+4 -4
View File
@@ -17,7 +17,7 @@ import {
import type { import type {
GenerateRegistrationOptionsOpts, GenerateRegistrationOptionsOpts,
GenerateAuthenticationOptionsOpts, GenerateAuthenticationOptionsOpts,
AuthenticatorTransportFuture AuthenticatorTransport
} from "@simplewebauthn/server"; } from "@simplewebauthn/server";
import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { isoBase64URL } from "@simplewebauthn/server/helpers";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -221,7 +221,7 @@ export async function startRegistration(
const excludeCredentials = existingSecurityKeys.map((key) => ({ const excludeCredentials = existingSecurityKeys.map((key) => ({
id: key.credentialId, id: key.credentialId,
transports: key.transports transports: key.transports
? (JSON.parse(key.transports) as AuthenticatorTransportFuture[]) ? (JSON.parse(key.transports) as AuthenticatorTransport[])
: undefined : undefined
})); }));
@@ -571,7 +571,7 @@ export async function startAuthentication(
transports: key.transports transports: key.transports
? (JSON.parse( ? (JSON.parse(
key.transports key.transports
) as AuthenticatorTransportFuture[]) ) as AuthenticatorTransport[])
: undefined : undefined
})); }));
} }
@@ -702,7 +702,7 @@ export async function verifyAuthentication(
transports: securityKey.transports transports: securityKey.transports
? (JSON.parse( ? (JSON.parse(
securityKey.transports securityKey.transports
) as AuthenticatorTransportFuture[]) ) as AuthenticatorTransport[])
: undefined : undefined
}, },
requireUserVerification: false requireUserVerification: false
+24
View File
@@ -0,0 +1,24 @@
import { sendToClient } from "#dynamic/routers/ws";
// Error codes for registration failures
export const NewtErrorCodes = {
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
}
} as const;
// Helper function to send registration error
export async function sendNewtError(
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
newtId: string
) {
sendToClient(newtId, {
type: "newt/error",
data: {
code: error.code,
message: error.message
}
});
}
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import { NewtErrorCodes, sendNewtError } from "./error";
export const handleNewtRegisterMessage: MessageHandler = async (context) => { export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
logger.error( logger.error(
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}` `No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
); );
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
return; return;
} }
+5
View File
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
HOLEPUNCH_MISSING: { HOLEPUNCH_MISSING: {
code: "HOLEPUNCH_MISSING", code: "HOLEPUNCH_MISSING",
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.` message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
},
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
} }
} as const; } as const;
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`, `[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
return; return;
} }
@@ -43,7 +43,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
const { message, client: c } = context; const { message, client: c } = context;
const newt = c as Newt; const newt = c as Newt;
logger.info("Handling healthcheck status message"); logger.debug("Handling healthcheck status message");
if (!newt) { if (!newt) {
logger.warn("Newt not found"); logger.warn("Newt not found");
+2 -1
View File
@@ -5,6 +5,7 @@ import path from "path";
import semver from "semver"; import semver from "semver";
import { versionMigrations } from "../db/sqlite"; import { versionMigrations } from "../db/sqlite";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { SqliteError } from "better-sqlite3"; import { SqliteError } from "better-sqlite3";
import fs from "fs"; import fs from "fs";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -121,7 +122,7 @@ function backupDb() {
// copy the db.sqlite file to backups // copy the db.sqlite file to backups
// add the date to the filename // add the date to the filename
const date = new Date(); const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; const dateString = formatBackupTimestamp(date);
const dbPath = path.join(dbDir, "db.sqlite"); const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath); fs.copyFileSync(dbPath, backupPath);
+2 -4
View File
@@ -3,8 +3,6 @@ import { sql } from "drizzle-orm";
const version = "1.23.0"; const version = "1.23.0";
await migration();
export default async function migration() { export default async function migration() {
console.log(`Running setup script ${version}...`); console.log(`Running setup script ${version}...`);
@@ -12,11 +10,11 @@ export default async function migration() {
await db.execute(sql`BEGIN`); await db.execute(sql`BEGIN`);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agent" varchar; ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agent" varchar;
`); `);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agentVersion" varchar; ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agentVersion" varchar;
`); `);
await db.execute(sql`COMMIT`); await db.execute(sql`COMMIT`);
+2 -1
View File
@@ -10,6 +10,7 @@ import {
users users
} from "../../db/sqlite"; } from "../../db/sqlite";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
@@ -34,7 +35,7 @@ export default async function migration() {
// copy the db.sqlite file to backups // copy the db.sqlite file to backups
// add the date to the filename // add the date to the filename
const date = new Date(); const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; const dateString = formatBackupTimestamp(date);
const dbPath = path.join(dbDir, "db.sqlite"); const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath); fs.copyFileSync(dbPath, backupPath);
+2 -2
View File
@@ -16,13 +16,13 @@ export default async function migration() {
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
` `
ALTER TABLE 'newt' ADD 'agent' text; ALTER TABLE 'newt' ADD COLUMN 'agent' text;
` `
).run(); ).run();
db.prepare( db.prepare(
` `
ALTER TABLE 'newt' ADD 'agentVersion' text; ALTER TABLE 'newt' ADD COLUMN 'agentVersion' text;
` `
).run(); ).run();
})(); })();
@@ -749,12 +749,6 @@ export default function BillingPage() {
return 0; return 0;
}; };
// Get license key count
const getLicenseKeyCount = (): number => {
if (!licenseSubscription?.items) return 0;
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits // Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = ( const checkLimitViolations = (
targetTier: Tier | "basic" targetTier: Tier | "basic"
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
</SettingsSection> </SettingsSection>
{/* Paid License Keys Section */} {/* Paid License Keys Section */}
{(licenseSubscription || getLicenseKeyCount() > 0) && ( {licenseSubscription && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
onClick={handleModifySubscription} onClick={handleModifySubscription}
@@ -1,4 +1,5 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable"; import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
licenseKeys = data.data.data; licenseKeys = data.data.data;
} catch {} } catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />; const hasNonPersonalLicenseKey = licenseKeys.some(
(key) => key.tier !== "personal"
);
return (
<>
{hasNonPersonalLicenseKey && (
<LicenseBillingBanner orgId={orgId} />
)}
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
</>
);
} }
@@ -76,11 +76,13 @@ export default function PrivateResourceInferencePage() {
}) })
), ),
httpConfigSubdomain: z.string().nullish(), httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(), httpConfigDomainId: z
.string()
.min(1, { message: t("domainRequired") }),
httpConfigFullDomain: z.string().nullish(), httpConfigFullDomain: z.string().nullish(),
ssl: z.boolean().optional() ssl: z.boolean().optional()
}), }),
[] [t]
); );
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -103,7 +105,7 @@ export default function PrivateResourceInferencePage() {
defaultValues: { defaultValues: {
providers: [], providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null, httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null, httpConfigDomainId: siteResource.domainId ?? "",
httpConfigFullDomain: siteResource.fullDomain ?? null, httpConfigFullDomain: siteResource.fullDomain ?? null,
ssl: siteResource.ssl ?? false ssl: siteResource.ssl ?? false
} }
@@ -289,50 +291,74 @@ export default function PrivateResourceInferencePage() {
</SettingsSubsectionHeader> </SettingsSubsectionHeader>
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<DomainPicker <FormField
key={`inference-domain-${siteResource.id}`} control={form.control}
orgId={siteResource.orgId} name="httpConfigDomainId"
cols={2} render={() => (
hideFreeDomain <FormItem>
defaultSubdomain={ <DomainPicker
httpConfigSubdomain ?? undefined key={`inference-domain-${siteResource.id}`}
} orgId={
defaultDomainId={ siteResource.orgId
httpConfigDomainId ?? undefined }
} cols={2}
defaultFullDomain={ hideFreeDomain
httpConfigFullDomain ?? defaultSubdomain={
undefined httpConfigSubdomain ??
} undefined
onDomainChange={(res) => { }
if (res === null) { defaultDomainId={
form.setValue( httpConfigDomainId ??
"httpConfigSubdomain", undefined
null }
); defaultFullDomain={
form.setValue( httpConfigFullDomain ??
"httpConfigDomainId", undefined
null }
); onDomainChange={(
form.setValue( res
"httpConfigFullDomain", ) => {
null if (res === null) {
); form.setValue(
return; "httpConfigSubdomain",
} null
form.setValue( );
"httpConfigSubdomain", form.setValue(
res.subdomain ?? null "httpConfigDomainId",
); "",
form.setValue( {
"httpConfigDomainId", shouldValidate:
res.domainId true
); }
form.setValue( );
"httpConfigFullDomain", form.setValue(
res.fullDomain "httpConfigFullDomain",
); null
}} );
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
</FormItem>
)}
/> />
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
@@ -139,6 +139,22 @@ export default function GeneralForm() {
: "Port number should not be set for HTTP resources", : "Port number should not be set for HTTP resources",
path: ["proxyPort"] path: ["proxyPort"]
} }
)
.refine(
(data) => {
if (
["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
) {
return !!data.domainId;
}
return true;
},
{
message: t("domainRequired"),
path: ["domainId"]
}
); );
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -434,63 +450,87 @@ export default function GeneralForm() {
resource.mode resource.mode
) && ( ) && (
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<div id="resource-domain-picker"> <FormField
<DomainPicker control={form.control}
allowWildcard={ name="domainId"
resource.mode !== render={() => (
"inference" <FormItem>
} <div id="resource-domain-picker">
key={ <DomainPicker
resource.resourceId allowWildcard={
} resource.mode !==
orgId={orgId as string} "inference"
cols={2} }
defaultSubdomain={ key={
form.watch( resource.resourceId
"subdomain" }
) ?? undefined orgId={
} orgId as string
defaultDomainId={ }
form.watch( cols={2}
"domainId" defaultSubdomain={
) ?? undefined form.watch(
} "subdomain"
defaultFullDomain={ ) ??
resourceFullDomainName || undefined
undefined }
} defaultDomainId={
onDomainChange={( form.watch(
res "domainId"
) => { ) ??
if (res === null) { undefined
form.setValue( }
"domainId", defaultFullDomain={
undefined resourceFullDomainName ||
); undefined
form.setValue( }
"subdomain", onDomainChange={(
undefined res
); ) => {
setResourceFullDomain( if (
`${resource.ssl ? "https" : "http"}://` res ===
); null
return; ) {
} form.setValue(
form.setValue( "domainId",
"domainId", undefined,
res.domainId {
); shouldValidate:
form.setValue( true
"subdomain", }
res.subdomain ?? );
undefined form.setValue(
); "subdomain",
setResourceFullDomain( undefined
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}` );
); setResourceFullDomain(
}} `${resource.ssl ? "https" : "http"}://`
/> );
</div> return;
}
form.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell> </SettingsFormCell>
)} )}
{!["tcp", "udp", "inference"].includes( {!["tcp", "udp", "inference"].includes(
+14 -13
View File
@@ -252,9 +252,10 @@ export default function GenerateLicenseKeyForm({
try { try {
// Check if this is a business/enterprise license request // Check if this is a business/enterprise license request
if (payload.useCaseType === "business") { if (payload.useCaseType === "business") {
const response = await api.put< const response = await api.put<AxiosResponse<string>>(
AxiosResponse<string> `/org/${orgId}/license/enterprise`,
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } ); { ...payload, tier: "tier2" }
);
console.log("Checkout session response:", response.data); console.log("Checkout session response:", response.data);
const checkoutUrl = response.data.data; const checkoutUrl = response.data.data;
@@ -1087,16 +1088,16 @@ export default function GenerateLicenseKeyForm({
)} )}
{!generatedKey && useCaseType === "business" && ( {!generatedKey && useCaseType === "business" && (
<Button <Button
type="submit" type="submit"
form="generate-license-business-form" form="generate-license-business-form"
disabled={loading} disabled={loading}
loading={loading} loading={loading}
> >
{t( {t(
"generateLicenseKeyForm.buttons.generateLicenseKey" "generateLicenseKeyForm.buttons.generateLicenseKey"
)} )}
</Button> </Button>
)} )}
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
+10 -3
View File
@@ -201,9 +201,16 @@ export default function GenerateLicenseKeysTable({
}, },
cell: ({ row }) => { cell: ({ row }) => {
const tier = row.original.tier; const tier = row.original.tier;
return tier === "enterprise" switch (tier) {
? t("licenseTierEnterprise") case "enterprise":
: t("licenseTierPersonal"); return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
default:
return t("licenseTierPersonal");
}
} }
}, },
{ {
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Globe, CreditCard, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import DismissableBanner from "./DismissableBanner";
type LicenseBillingBannerProps = {
orgId: string;
};
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="license-billing-banner-dismissed"
version={1}
title={t("licenseBillingBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("licenseBillingBannerDescription")}
>
<Link href={`/${orgId}/settings/billing`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
{t("licenseBillingBannerButton")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default LicenseBillingBanner;
+10 -3
View File
@@ -100,9 +100,16 @@ export function LicenseKeysDataTable({
}, },
cell: ({ row }) => { cell: ({ row }) => {
const tier = row.original.tier; const tier = row.original.tier;
return tier === "enterprise" switch (tier) {
? t("licenseTierEnterprise") case "enterprise":
: t("licenseTierPersonal"); return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
default:
return t("licenseTierPersonal");
}
} }
}, },
{ {
+3 -3
View File
@@ -40,8 +40,8 @@ import { InfoIcon } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
const TIER_TO_LICENSE_ID = { const TIER_TO_LICENSE_ID = {
starter: "small_license", starter: "tier1",
scale: "big_license" scale: "tier2"
} as const; } as const;
type FormProps = { type FormProps = {
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
cols={2} cols={2}
/> />
<a <a
href="https://pangolin.net/pricing" href="https://pangolin.net/pricing#Self-Hosted"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
+12 -7
View File
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
) : null; ) : null;
if (site.type === "newt") { if (site.type === "newt") {
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
const agentVersion = site.agentVersion ?? site.newtVersion;
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("agent")}</InfoSectionTitle> <InfoSectionTitle>{t("agent")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.agent == "newt" ? "Newt" : null} <div className="flex items-center space-x-1">
{site.agent == "cli" <span>{agentLabel}</span>
? "Pangolin CLI" {agentVersion && (
: null}{" "} <span>v{agentVersion}</span>
{site.agentVersion )}
? `v${site.agentVersion}` </div>
: "-"}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{endpointSection} {endpointSection}
+16 -14
View File
@@ -373,7 +373,7 @@ export default function SitesTable({
accessorKey: "type", accessorKey: "type",
friendlyName: t("agent"), friendlyName: t("agent"),
header: () => { header: () => {
return <span className="p-3">{t("type")}</span>; return <span className="p-3">{t("agent")}</span>;
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -386,25 +386,27 @@ export default function SitesTable({
); );
if (originalRow.type === "newt") { if (originalRow.type === "newt") {
if (!originalRow.agent) { if (!originalRow.agent && !originalRow.newtVersion) {
// it has not checked in yet
return <span>-</span>; return <span>-</span>;
} }
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel =
originalRow.agent == "cli"
? "Pangolin CLI"
: "Newt";
const agentVersion =
originalRow.agentVersion ?? originalRow.newtVersion;
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Badge variant="secondary"> <Badge variant="secondary">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<span> <span>{agentLabel}</span>
{originalRow.agent == "newt" {agentVersion && (
? "Newt" <span>v{agentVersion}</span>
: null}
{originalRow.agent == "cli"
? "Pangolin CLI"
: null}
</span>
{originalRow.agentVersion && (
<span>
v{originalRow.agentVersion}
</span>
)} )}
</div> </div>
</Badge> </Badge>
+1 -1
View File
@@ -150,7 +150,7 @@ Type=simple
User=root User=root
Group=root Group=root
EnvironmentFile=/etc/pangolin/pangolin-site.env EnvironmentFile=/etc/pangolin/pangolin-site.env
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site ExecStart=/usr/local/bin/pangolin up site
Restart=always Restart=always
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077
+1 -1
View File
@@ -87,7 +87,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label defaultClassNames.caption_label
), ),
table: "w-full border-collapse", month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+7
View File
@@ -523,6 +523,13 @@ export function createCreateFormSchema(t: TranslateFn) {
}); });
} }
} }
if (data.mode === "inference" && !data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
}); });
} }