Compare commits

..

78 Commits

Author SHA1 Message Date
Owen
a5bab6bb80 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-10-19 12:04:59 -07:00
miloschwartz
7536c03f63 add int api routes for add/remote email to resource email whitelist 2025-10-19 12:04:20 -07:00
Owen
ada5d2ef0e Update domain 2025-10-19 11:59:10 -07:00
Owen
b8bead0590 Select exit node for local sites 2025-10-19 11:13:33 -07:00
Milo Schwartz
68f852d6d1 Merge pull request #1699 from Pallavikumarimdb/make-easier-to-delete
Make it easier to delete things
2025-10-19 14:00:19 -04:00
Owen
d9fe5a8819 Always set exit node to online
Fixes #1692
2025-10-19 10:47:32 -07:00
Owen
346183a23f Only allow nodes to pull with defined exitNodeID 2025-10-19 10:46:25 -07:00
Owen
dcfd7f5443 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-10-19 10:43:39 -07:00
Pallavi Kumari
e59cd6672b fix space 2025-10-19 22:23:57 +05:30
Pallavi Kumari
7c8c440f67 fix text 2025-10-19 21:36:47 +05:30
Pallavi Kumari
f258c41f15 easier to delete 2025-10-19 20:37:07 +05:30
Pallavi Kumari
ae4a24f4aa easier to delete resources 2025-10-19 15:50:00 +05:30
Pallavi Kumari
476cdcfe86 easier to delete sites 2025-10-19 15:02:35 +05:30
Owen
f869df2f65 Working on fixing exit node issue 2025-10-18 21:32:26 -07:00
Owen Schwartz
03cfabacd9 Merge pull request #1695 from Pallavikumarimdb/fix/rule-priority-input
Make priority input box focused on pressing the up/down arrows
2025-10-18 13:38:54 -07:00
miloschwartz
47ac5875f3 change digpangolin.com to pangolin.net 2025-10-18 11:51:09 -07:00
miloschwartz
f67327358e Merge branch 'main' into dev 2025-10-18 11:41:06 -07:00
Milo Schwartz
4901823f15 Update README.md 2025-10-18 14:25:22 -04:00
Pallavi Kumari
5407e3c821 make priority input box focus on up/down click 2025-10-18 23:38:14 +05:30
Owen Schwartz
1d5cdad8b7 Merge pull request #1693 from Pallavikumarimdb/fix/sorting-resources-alphabetically-by-default
Sorting Resources Alphabetically by Default
2025-10-18 10:03:28 -07:00
Owen Schwartz
cd2424cb77 Merge pull request #1691 from fosrl/dependabot/npm_and_yarn/prod-patch-updates-30703f013a
Bump the prod-patch-updates group across 1 directory with 4 updates
2025-10-18 10:03:23 -07:00
Pallavi Kumari
c17efde6bf Sorting Resources Alphabetically by Default 2025-10-18 14:43:54 +05:30
Owen
40cd8cdec7 Merge branch 'dev' 2025-10-17 16:25:01 -07:00
Owen
6768672a44 Merge branch 'main' of github.com:fosrl/pangolin 2025-10-17 16:24:55 -07:00
Owen
240c5b005b Add more transactions support 2025-10-17 16:22:43 -07:00
dependabot[bot]
8dde170a35 Bump the prod-patch-updates group across 1 directory with 4 updates
Bumps the prod-patch-updates group with 3 updates in the / directory: [@react-email/components](https://github.com/resend/react-email/tree/HEAD/packages/components), [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [next](https://github.com/vercel/next.js).


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

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

Updates `eslint-config-next` from 15.5.5 to 15.5.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.6/packages/eslint-config-next)

Updates `next` from 15.5.5 to 15.5.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.5...v15.5.6)

---
updated-dependencies:
- dependency-name: "@react-email/components"
  dependency-version: 0.5.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@react-email/render"
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-patch-updates
- dependency-name: eslint-config-next
  dependency-version: 15.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: next
  dependency-version: 15.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-17 21:06:44 +00:00
Owen
c07abf8ff9 Pass through transaction 2025-10-17 14:05:17 -07:00
Owen
e5a436593f Delete all before migrating 2025-10-17 14:05:17 -07:00
Owen
bb6e093ac6 Priority needs to be def 2025-10-17 14:05:17 -07:00
Milo Schwartz
59a334ce24 Update README.md 2025-10-17 14:05:17 -07:00
Owen
d241dcfb27 Fix typo 2025-10-17 14:05:17 -07:00
Owen
af263e7913 Pass through transaction 2025-10-17 14:04:49 -07:00
Owen Schwartz
6610e7d405 Merge pull request #1673 from fosrl/dependabot/npm_and_yarn/prod-patch-updates-ac45ae572b
Bump the prod-patch-updates group across 1 directory with 2 updates
2025-10-17 14:02:36 -07:00
Owen Schwartz
c476e65cf2 Merge pull request #1677 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-3f2a7d9f8f
Bump the dev-patch-updates group across 1 directory with 2 updates
2025-10-17 14:01:57 -07:00
Owen Schwartz
b69b2eeeb3 Merge pull request #1689 from barnabehvrd/patch-2
FR translation update
2025-10-17 13:57:09 -07:00
Barnabé Havard
89dab0917b Fixed (again ...) indentation issues 2025-10-17 22:42:07 +02:00
Barnabé Havard
73efdb95ae Fixed indentation issues 2025-10-17 22:36:08 +02:00
Barnabé Havard
1bcca88614 Updated several translation 2025-10-17 22:32:51 +02:00
Owen
3af1e0ef56 Delete all before migrating 2025-10-17 11:56:19 -07:00
Owen Schwartz
8387571c1d Merge pull request #1684 from Pallavikumarimdb/fix/make-priority-optional
Make priority optional in schema
2025-10-17 10:14:01 -07:00
Pallavi Kumari
1d017f60b4 make priority optional in schema 2025-10-17 19:51:32 +05:30
dependabot[bot]
81effda9e8 Bump the prod-patch-updates group across 1 directory with 2 updates
Bumps the prod-patch-updates group with 2 updates in the / directory: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [next](https://github.com/vercel/next.js).


Updates `eslint-config-next` from 15.5.4 to 15.5.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.5/packages/eslint-config-next)

Updates `next` from 15.5.4 to 15.5.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.4...v15.5.5)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 15.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: next
  dependency-version: 15.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-17 01:22:32 +00:00
dependabot[bot]
9343906ab1 Bump the dev-patch-updates group across 1 directory with 2 updates
Bumps the dev-patch-updates group with 2 updates in the / directory: [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) and [esbuild](https://github.com/evanw/esbuild).


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

Updates `esbuild` from 0.25.10 to 0.25.11
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.10...v0.25.11)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: esbuild
  dependency-version: 0.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-17 01:20:39 +00:00
Owen
08b7d6735c Priority needs to be def 2025-10-16 14:52:14 -07:00
Milo Schwartz
a91ebd1e91 Update README.md 2025-10-16 17:45:11 -04:00
Owen
312e03b4eb Fix typo 2025-10-16 14:43:11 -07:00
miloschwartz
e8a57e432c hide path match and rewrite in raw resource 2025-10-16 14:30:22 -07:00
Owen
bca2eef2e8 Show ssl toggle 2025-10-16 14:24:36 -07:00
Owen
ec7211a15d Handle updating exit node and fix raw resource issues 2025-10-16 13:55:08 -07:00
Owen
46807c6477 Fix various bugs 2025-10-16 10:23:25 -07:00
miloschwartz
b578786e62 add empty state to sites table cols 2025-10-16 10:11:50 -07:00
miloschwartz
2e0ad8d262 branding only works when licensed 2025-10-15 22:07:33 -07:00
miloschwartz
003f0cfa6d fix target validation on create site 2025-10-15 20:43:59 -07:00
Owen
ee3df081ef Fix docker button and positioning 2025-10-15 20:21:15 -07:00
Owen
08eeb12519 Fix going away when creating target
cd8062ada3
2025-10-15 17:48:31 -07:00
Owen
e66c6b2505 remove volumes for remote nodes 2025-10-15 17:44:03 -07:00
miloschwartz
d2a880d9c8 update docker command in makefile 2025-10-15 17:36:09 -07:00
miloschwartz
edc0b86470 add translation and update url 2025-10-15 17:32:39 -07:00
Owen
aebe6b80b7 Make private file optional 2025-10-15 17:22:43 -07:00
Owen
4d87333b43 Merge branch 'main' into dev 2025-10-15 17:15:48 -07:00
Owen
ef32f3ed5a Load encryption file dynamically 2025-10-15 17:14:24 -07:00
Owen
216ded3034 Merge branch 'main' of github.com:fosrl/pangolin 2025-10-15 17:14:14 -07:00
miloschwartz
cb59fe2cee update readme 2025-10-15 16:34:06 -07:00
miloschwartz
7776f6d09c disable branding 2025-10-15 16:32:16 -07:00
Owen
c50392c947 Remove logging 2025-10-15 13:57:42 -07:00
Owen
ceee978fcd Merge branch 'dev' 2025-10-15 12:13:15 -07:00
Owen
c5a73dc87e Try to handle the certs better 2025-10-15 12:12:59 -07:00
Owen
7198ef2774 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-10-15 11:12:38 -07:00
miloschwartz
7e9a066797 update form 2025-10-15 11:10:37 -07:00
Milo Schwartz
ba96332313 Update README.md 2025-10-15 14:02:28 -04:00
Owen
e2d0338b0b Merge branch 'dev' 2025-10-15 10:39:50 -07:00
Owen
59ecab5738 Dont ping remote nodes; handle certs better 2025-10-15 10:39:45 -07:00
miloschwartz
721bf3403d fix form 2025-10-15 10:21:00 -07:00
Owen
3b8ba47377 Update package lock 2025-10-14 18:00:46 -07:00
Milo Schwartz
e752929f69 Update README.md 2025-10-14 20:50:41 -04:00
Milo Schwartz
e41c3e6f54 Update README.md 2025-10-14 20:48:44 -04:00
Milo Schwartz
9dedd1a8de Update README.md 2025-10-14 20:41:14 -04:00
Owen
c4a5fae28f Update workflow and add runner 2025-10-14 17:34:47 -07:00
107 changed files with 6802 additions and 2681 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs:
release:
name: Build and Release
runs-on: ubuntu-latest
runs-on: amd64-runner
steps:
- name: Checkout code

View File

@@ -4,7 +4,7 @@ Contributions are welcome!
Please see the contribution and local development guide on the docs page before getting started:
https://docs.digpangolin.com/development/contributing
https://docs.pangolin.net/development/contributing
### Licensing Considerations

View File

@@ -8,7 +8,7 @@ build-release:
exit 1; \
fi
docker buildx build \
--build-arg BUILD=oss
--build-arg BUILD=oss \
--build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:latest \
@@ -17,7 +17,7 @@ build-release:
--tag fosrl/pangolin:$(tag) \
--push .
docker buildx build \
--build-arg BUILD=oss
--build-arg BUILD=oss \
--build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:postgresql-latest \
@@ -25,6 +25,24 @@ build-release:
--tag fosrl/pangolin:postgresql-$(minor_tag) \
--tag fosrl/pangolin:postgresql-$(tag) \
--push .
docker buildx build \
--build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-latest \
--tag fosrl/pangolin:ee-$(major_tag) \
--tag fosrl/pangolin:ee-$(minor_tag) \
--tag fosrl/pangolin:ee-$(tag) \
--push .
docker buildx build \
--build-arg BUILD=enterprise \
--build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-postgresql-latest \
--tag fosrl/pangolin:ee-postgresql-$(major_tag) \
--tag fosrl/pangolin:ee-postgresql-$(minor_tag) \
--tag fosrl/pangolin:ee-postgresql-$(tag) \
--push .
build-arm:
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .

View File

@@ -1,6 +1,6 @@
<div align="center">
<h2>
<a href="https://digpangolin.com">
<a href="https://pangolin.net/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/logo/word_mark_white.png">
<img alt="Pangolin Logo" src="public/logo/word_mark_black.png" width="350">
@@ -11,15 +11,15 @@
<div align="center">
<h5>
<a href="https://digpangolin.com">
<a href="https://pangolin.net/">
Website
</a>
<span> | </span>
<a href="https://docs.digpangolin.com/">
<a href="https://docs.pangolin.net/">
Documentation
</a>
<span> | </span>
<a href="mailto:contact@fossorial.io">
<a href="mailto:contact@pangolin.net">
Contact Us
</a>
</h5>
@@ -28,26 +28,32 @@
<div align="center">
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)
[![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://digpangolin.com/slack)
[![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square)
[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
</div>
<p align="center">
<strong>
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
</strong>
</p>
Pangolin is a self-hosted tunneled reverse proxy server with identity and context aware access control, designed to easily expose and protect applications running anywhere. Pangolin acts as a central hub and connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports or requiring a VPN.
## Installation
Check out the [quick install guide](https://docs.digpangolin.com) for how to install and set up Pangolin.
Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin.
## Deployment Options
| <img width=500 /> | Description |
|-----------------|--------------|
| **Self-Host: Community Edition** | Free, open source, and AGPL-3 compliant. |
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://github.com/fosrl/remote-note) and connect to our control plane. |
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/nodes) and connect to our control plane. |
## Key Features
@@ -55,27 +61,27 @@ Pangolin packages everything you need for seamless application access and exposu
| <img width=500 /> | <img width=500 /> |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" /><tr></tr> |
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" /><tr></tr> |
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" /><tr></tr> |
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" /><tr></tr> |
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" width=500 /><tr></tr> |
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" width=500 /><tr></tr> |
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" width=500 /><tr></tr> |
## Get Started
### Check out the docs
We encourage everyone to read the full documentation first, which is
available at [docs.digpangolin.com](https://docs.digpangolin.com). This README provides only a very brief subset of
available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of
the docs to illustrate some basic ideas.
### Sign up and try now
For Pangolin's managed service, you will first need to create an account at
[pangolin.fossorial.io](https://pangolin.fossorial.io). We have a generous free tier to get started.
[app.pangolin.net](https://app.pangolin.net). We have a generous free tier to get started.
## Licensing
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://digpangolin.com/fcl.html). For inquiries about commercial licensing, please contact us at [contact@fossorial.io](mailto:contact@fossorial.io).
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
## Contributions

View File

@@ -3,7 +3,7 @@
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
- Description and location of the vulnerability.
- Potential impact of the vulnerability.

View File

@@ -8,7 +8,7 @@ import base64
YAML_FILE_PATH = 'blueprint.yaml'
# The API endpoint and headers from the curl request
API_URL = 'http://api.pangolin.fossorial.io/v1/org/test/blueprint'
API_URL = 'http://api.pangolin.net/v1/org/test/blueprint'
HEADERS = {
'accept': '*/*',
'Authorization': 'Bearer <your_token_here>',

View File

@@ -28,9 +28,9 @@ proxy-resources:
# sso-roles:
# - Member
# sso-users:
# - owen@fossorial.io
# - owen@pangolin.net
# whitelist-users:
# - owen@fossorial.io
# - owen@pangolin.net
headers:
- name: X-Example-Header
value: example-value

View File

@@ -12,7 +12,7 @@ post {
body:json {
{
"email": "owen@fossorial.io",
"email": "owen@pangolin.net",
"password": "Password123!"
}
}

View File

@@ -12,6 +12,6 @@ post {
body:json {
{
"email": "milo@fossorial.io"
"email": "milo@pangolin.net"
}
}

View File

@@ -12,7 +12,7 @@ put {
body:json {
{
"email": "numbat@fossorial.io",
"email": "numbat@pangolin.net",
"password": "Password123!"
}
}

View File

@@ -1,5 +1,5 @@
# To see all available options, please visit the docs:
# https://docs.digpangolin.com/self-host/advanced/config-file
# https://docs.pangolin.net/self-host/advanced/config-file
app:
dashboard_url: http://localhost:3002

View File

@@ -20,7 +20,7 @@ services:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3003
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:

View File

@@ -1,5 +1,5 @@
# To see all available options, please visit the docs:
# https://docs.digpangolin.com/
# https://docs.pangolin.net/
gerbil:
start_port: 51820
@@ -36,4 +36,4 @@ flags:
require_email_verification: {{.EnableEmail}}
disable_signup_without_invite: true
disable_user_create_org: false
allow_raw_resources: true
allow_raw_resources: true

View File

@@ -6,8 +6,6 @@ services:
restart: unless-stopped
volumes:
- ./config:/app/config
- pangolin-data-certificates:/var/certificates
- pangolin-data-dynamic:/var/dynamic
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "10s"
@@ -22,7 +20,7 @@ services:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3003
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
@@ -56,16 +54,9 @@ services:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
# Shared volume for certificates and dynamic config in file mode
- pangolin-data-certificates:/var/certificates:ro
- pangolin-data-dynamic:/var/dynamic:ro
networks:
default:
driver: bridge
name: pangolin
{{if .EnableIPv6}} enable_ipv6: true{{end}}
volumes:
pangolin-data-dynamic:
pangolin-data-certificates:
{{if .EnableIPv6}} enable_ipv6: true{{end}}

180
install/get-installer.sh Normal file
View File

@@ -0,0 +1,180 @@
#!/bin/bash
# Get installer - Cross-platform installation script
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/installer/refs/heads/main/get-installer.sh | bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# GitHub repository info
REPO="fosrl/pangolin"
GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest"
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to get latest version from GitHub API
get_latest_version() {
local latest_info
if command -v curl >/dev/null 2>&1; then
latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null)
elif command -v wget >/dev/null 2>&1; then
latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null)
else
print_error "Neither curl nor wget is available. Please install one of them." >&2
exit 1
fi
if [ -z "$latest_info" ]; then
print_error "Failed to fetch latest version information" >&2
exit 1
fi
# Extract version from JSON response (works without jq)
local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
if [ -z "$version" ]; then
print_error "Could not parse version from GitHub API response" >&2
exit 1
fi
# Remove 'v' prefix if present
version=$(echo "$version" | sed 's/^v//')
echo "$version"
}
# Detect OS and architecture
detect_platform() {
local os arch
# Detect OS - only support Linux
case "$(uname -s)" in
Linux*) os="linux" ;;
*)
print_error "Unsupported operating system: $(uname -s). Only Linux is supported."
exit 1
;;
esac
# Detect architecture - only support amd64 and arm64
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
*)
print_error "Unsupported architecture: $(uname -m). Only amd64 and arm64 are supported on Linux."
exit 1
;;
esac
echo "${os}_${arch}"
}
# Get installation directory
get_install_dir() {
# Install to the current directory
local install_dir="$(pwd)"
if [ ! -d "$install_dir" ]; then
print_error "Installation directory does not exist: $install_dir"
exit 1
fi
echo "$install_dir"
}
# Download and install installer
install_installer() {
local platform="$1"
local install_dir="$2"
local binary_name="installer_${platform}"
local download_url="${BASE_URL}/${binary_name}"
local temp_file="/tmp/installer"
local final_path="${install_dir}/installer"
print_status "Downloading installer from ${download_url}"
# Download the binary
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$download_url" -o "$temp_file"
elif command -v wget >/dev/null 2>&1; then
wget -q "$download_url" -O "$temp_file"
else
print_error "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Create install directory if it doesn't exist
mkdir -p "$install_dir"
# Move binary to install directory
mv "$temp_file" "$final_path"
# Make executable
chmod +x "$final_path"
print_status "Installer downloaded to ${final_path}"
}
# Verify installation
verify_installation() {
local install_dir="$1"
local installer_path="${install_dir}/installer"
if [ -f "$installer_path" ] && [ -x "$installer_path" ]; then
print_status "Installation successful!"
return 0
else
print_error "Installation failed. Binary not found or not executable."
return 1
fi
}
# Main installation process
main() {
print_status "Installing latest version of installer..."
# Get latest version
print_status "Fetching latest version from GitHub..."
VERSION=$(get_latest_version)
print_status "Latest version: v${VERSION}"
# Set base URL with the fetched version
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
# Detect platform
PLATFORM=$(detect_platform)
print_status "Detected platform: ${PLATFORM}"
# Get install directory
INSTALL_DIR=$(get_install_dir)
print_status "Install directory: ${INSTALL_DIR}"
# Install installer
install_installer "$PLATFORM" "$INSTALL_DIR"
# Verify installation
if verify_installation "$INSTALL_DIR"; then
print_status "Installer is ready to use!"
else
exit 1
fi
}
# Run main function
main "$@"

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Безплатен план",
"billingWarningOverLimit": "Предупреждение: Превишили сте една или повече лимити за използване. Вашите сайтове няма да се свържат, докато не промените абонамента си или не коригирате използването.",
"billingUsageLimitsOverview": "Преглед на лимитите за използване",
"billingMonitorUsage": "Следете използването спрямо конфигурираните лимити. Ако ви е необходимо увеличаване на лимитите, моля, свържете се с нас на support@fossorial.io.",
"billingMonitorUsage": "Следете използването спрямо конфигурираните лимити. Ако ви е необходимо увеличаване на лимитите, моля, свържете се с нас на support@pangolin.net.",
"billingDataUsage": "Използване на данни",
"billingOnlineTime": "Време на работа на сайта",
"billingUsers": "Активни потребители",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Volná úroveň",
"billingWarningOverLimit": "Upozornění: Překročili jste jeden nebo více omezení používání. Vaše stránky se nepřipojí dokud nezměníte předplatné nebo neupravíte své používání.",
"billingUsageLimitsOverview": "Přehled omezení použití",
"billingMonitorUsage": "Sledujte vaše využití pomocí nastavených limitů. Pokud potřebujete zvýšit limity, kontaktujte nás prosím support@fossorial.io.",
"billingMonitorUsage": "Sledujte vaše využití pomocí nastavených limitů. Pokud potřebujete zvýšit limity, kontaktujte nás prosím support@pangolin.net.",
"billingDataUsage": "Využití dat",
"billingOnlineTime": "Stránka online čas",
"billingUsers": "Aktivní uživatelé",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Kostenlose Stufe",
"billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Webseiten werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.",
"billingUsageLimitsOverview": "Übersicht über Nutzungsgrenzen",
"billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@fossorial.io.",
"billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@pangolin.net.",
"billingDataUsage": "Datenverbrauch",
"billingOnlineTime": "Online-Zeit der Seite",
"billingUsers": "Aktive Benutzer",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -47,9 +47,8 @@
"edit": "Edit",
"siteConfirmDelete": "Confirm Delete Site",
"siteDelete": "Delete Site",
"siteMessageRemove": "Once removed, the site will no longer be accessible. All resources and targets associated with the site will also be removed.",
"siteMessageConfirm": "To confirm, please type the name of the site below.",
"siteQuestionRemove": "Are you sure you want to remove the site {selectedSite} from the organization?",
"siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.",
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
"siteManageSites": "Manage Sites",
"siteDescription": "Allow connectivity to your network through secure tunnels",
"siteCreate": "Create Site",
@@ -154,8 +153,7 @@
"protected": "Protected",
"notProtected": "Not Protected",
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceMessageConfirm": "To confirm, please type the name of the resource below.",
"resourceQuestionRemove": "Are you sure you want to remove the resource {selectedResource} from the organization?",
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.",
"resourceRaw": "Raw TCP/UDP Resource",
@@ -220,7 +218,7 @@
"orgDeleteConfirm": "Confirm Delete Organization",
"orgMessageRemove": "This action is irreversible and will delete all associated data.",
"orgMessageConfirm": "To confirm, please type the name of the organization below.",
"orgQuestionRemove": "Are you sure you want to remove the organization {selectedOrg}?",
"orgQuestionRemove": "Are you sure you want to remove the organization?",
"orgUpdated": "Organization updated",
"orgUpdatedDescription": "The organization has been updated.",
"orgErrorUpdate": "Failed to update organization",
@@ -287,9 +285,8 @@
"apiKeysAdd": "Generate API Key",
"apiKeysErrorDelete": "Error deleting API key",
"apiKeysErrorDeleteMessage": "Error deleting API key",
"apiKeysQuestionRemove": "Are you sure you want to remove the API key {selectedApiKey} from the organization?",
"apiKeysQuestionRemove": "Are you sure you want to remove the API key from the organization?",
"apiKeysMessageRemove": "Once removed, the API key will no longer be able to be used.",
"apiKeysMessageConfirm": "To confirm, please type the name of the API key below.",
"apiKeysDeleteConfirm": "Confirm Delete API Key",
"apiKeysDelete": "Delete API Key",
"apiKeysManage": "Manage API Keys",
@@ -305,8 +302,7 @@
"userDeleteConfirm": "Confirm Delete User",
"userDeleteServer": "Delete User from Server",
"userMessageRemove": "The user will be removed from all organizations and be completely removed from the server.",
"userMessageConfirm": "To confirm, please type the name of the user below.",
"userQuestionRemove": "Are you sure you want to permanently delete {selectedUser} from the server?",
"userQuestionRemove": "Are you sure you want to permanently delete user from the server?",
"licenseKey": "License Key",
"valid": "Valid",
"numberOfSites": "Number of Sites",
@@ -339,7 +335,7 @@
"fossorialLicense": "View Fossorial Commercial License & Subscription Terms",
"licenseMessageRemove": "This will remove the license key and all associated permissions granted by it.",
"licenseMessageConfirm": "To confirm, please type the license key below.",
"licenseQuestionRemove": "Are you sure you want to delete the license key {selectedKey} ?",
"licenseQuestionRemove": "Are you sure you want to delete the license key ?",
"licenseKeyDelete": "Delete License Key",
"licenseKeyDeleteConfirm": "Confirm Delete License Key",
"licenseTitle": "Manage License Status",
@@ -372,7 +368,7 @@
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
"inviteRemoved": "Invitation removed",
"inviteRemovedDescription": "The invitation for {email} has been removed.",
"inviteQuestionRemove": "Are you sure you want to remove the invitation {email}?",
"inviteQuestionRemove": "Are you sure you want to remove the invitation?",
"inviteMessageRemove": "Once removed, this invitation will no longer be valid. You can always re-invite the user later.",
"inviteMessageConfirm": "To confirm, please type the email address of the invitation below.",
"inviteQuestionRegenerate": "Are you sure you want to regenerate the invitation for {email}? This will revoke the previous invitation.",
@@ -398,9 +394,8 @@
"userErrorOrgRemoveDescription": "An error occurred while removing the user.",
"userOrgRemoved": "User removed",
"userOrgRemovedDescription": "The user {email} has been removed from the organization.",
"userQuestionOrgRemove": "Are you sure you want to remove {email} from the organization?",
"userQuestionOrgRemove": "Are you sure you want to remove this user from the organization?",
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
"userMessageOrgConfirm": "To confirm, please type the name of the of the user below.",
"userRemoveOrgConfirm": "Confirm Remove User",
"userRemoveOrg": "Remove User from Organization",
"users": "Users",
@@ -742,7 +737,7 @@
"idpManageDescription": "View and manage identity providers in the system",
"idpDeletedDescription": "Identity provider deleted successfully",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Are you sure you want to permanently delete the identity provider {name}?",
"idpQuestionRemove": "Are you sure you want to permanently delete the identity provider?",
"idpMessageRemove": "This will remove the identity provider and all associated configurations. Users who authenticate through this provider will no longer be able to log in.",
"idpMessageConfirm": "To confirm, please type the name of the identity provider below.",
"idpConfirmDelete": "Confirm Delete Identity Provider",
@@ -1211,9 +1206,8 @@
"domainCreate": "Create Domain",
"domainCreatedDescription": "Domain created successfully",
"domainDeletedDescription": "Domain deleted successfully",
"domainQuestionRemove": "Are you sure you want to remove the domain {domain} from your account?",
"domainQuestionRemove": "Are you sure you want to remove the domain from your account?",
"domainMessageRemove": "Once removed, the domain will no longer be associated with your account.",
"domainMessageConfirm": "To confirm, please type the domain name below.",
"domainConfirmDelete": "Confirm Delete Domain",
"domainDelete": "Delete Domain",
"domain": "Domain",
@@ -1280,7 +1274,7 @@
"billingFreeTier": "Free Tier",
"billingWarningOverLimit": "Warning: You have exceeded one or more usage limits. Your sites will not connect until you modify your subscription or adjust your usage.",
"billingUsageLimitsOverview": "Usage Limits Overview",
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@fossorial.io.",
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
"billingDataUsage": "Data Usage",
"billingOnlineTime": "Site Online Time",
"billingUsers": "Active Users",
@@ -1563,9 +1557,8 @@
"searchRemoteExitNodes": "Search nodes...",
"remoteExitNodeAdd": "Add Node",
"remoteExitNodeErrorDelete": "Error deleting node",
"remoteExitNodeQuestionRemove": "Are you sure you want to remove the node {selectedNode} from the organization?",
"remoteExitNodeQuestionRemove": "Are you sure you want to remove the node from the organization?",
"remoteExitNodeMessageRemove": "Once removed, the node will no longer be accessible.",
"remoteExitNodeMessageConfirm": "To confirm, please type the name of the node below.",
"remoteExitNodeConfirmDelete": "Confirm Delete Node",
"remoteExitNodeDelete": "Delete Node",
"sidebarRemoteExitNodes": "Remote Nodes",
@@ -1819,7 +1812,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {
@@ -1839,7 +1832,7 @@
"companyPhoneNumber": "Company phone number",
"country": "Country",
"phoneNumberOptional": "Phone number (optional)",
"complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license."
"complianceConfirmation": "I confirm that the information I provided is accurate and that I am in compliance with the Fossorial Commercial License. Reporting inaccurate information or misidentifying use of the product is a violation of the license and may result in your key getting revoked."
},
"buttons": {
"close": "Close",
@@ -1893,5 +1886,10 @@
"pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip",
"pathRewriteStripLabel": "strip"
"pathRewriteStripLabel": "strip",
"sidebarEnableEnterpriseLicense": "Enable Enterprise License",
"cannotbeUndone": "This can not be undone.",
"toConfirm": "to confirm",
"deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?",
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site."
}

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Nivel Gratis",
"billingWarningOverLimit": "Advertencia: Has excedido uno o más límites de uso. Tus sitios no se conectarán hasta que modifiques tu suscripción o ajustes tu uso.",
"billingUsageLimitsOverview": "Descripción general de los límites de uso",
"billingMonitorUsage": "Monitorea tu uso comparado con los límites configurados. Si necesitas que aumenten los límites, contáctanos a soporte@fossorial.io.",
"billingMonitorUsage": "Monitorea tu uso comparado con los límites configurados. Si necesitas que aumenten los límites, contáctanos a soporte@pangolin.net.",
"billingDataUsage": "Uso de datos",
"billingOnlineTime": "Tiempo en línea del sitio",
"billingUsers": "Usuarios activos",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -6,45 +6,45 @@
"setupOrgName": "Nom de l'organisation",
"orgDisplayName": "Ceci est le nom d'affichage de votre organisation.",
"orgId": "ID de l'organisation",
"setupIdentifierMessage": "Ceci est l'identifiant unique pour votre organisation. Il est séparé du nom affiché.",
"setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.",
"setupIdentifierMessage": "Ceci est l'identifiant de votre organisation. Il est différent du nom affiché.",
"setupErrorIdentifier": "Cet identifiant est déjà pris. Veuillez en choisir un autre.",
"componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.",
"componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.",
"welcome": "Bienvenue à Pangolin",
"welcome": "Bienvenue sur Pangolin",
"welcomeTo": "Bienvenue chez",
"componentsCreateOrg": "Créer une organisation",
"componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.",
"componentsMember": "Vous {count, plural, =0 {n'} other {}}êtes membre {count, plural, =0 {d'aucune organisation} one {d'une organisation} other {de # organisations}}.",
"componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.",
"dismiss": "Refuser",
"componentsLicenseViolation": "Violation de licence : Ce serveur utilise des sites {usedSites} qui dépassent la limite autorisée des sites {maxSites} . Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.",
"componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!",
"inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder n'ait pas été acceptée ou n'est plus valide.",
"inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour cet utilisateur.",
"inviteLoginUser": "Assurez-vous que vous êtes bien connecté en tant qu'utilisateur correct.",
"inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour un utilisateur qui existe.",
"inviteCreateUser": "Veuillez d'abord créer un compte.",
"goHome": "Retour à la maison",
"inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation via laquelle vous essayez d'accéder n'ait pas été acceptée ou n'est plus valide.",
"inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation via laquelle vous essayez d'accéder ne soit pas pour cet utilisateur.",
"inviteLoginUser": "Assurez-vous d'etre bien connecté au bon compte.",
"inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation via laquelle vous essayez d'accéder ne soit pas pour un utilisateur qui existe.",
"inviteCreateUser": "Vous n'avez aucun compte, veuillez en créer un.",
"goHome": "Retour à l'accueil",
"inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent",
"createAnAccount": "Créer un compte",
"inviteNotAccepted": "Invitation non acceptée",
"authCreateAccount": "Créez un compte pour commencer",
"authNoAccount": "Vous n'avez pas de compte ?",
"email": "Courriel",
"email": "Adresse email",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"createAccount": "Créer un compte",
"viewSettings": "Afficher les paramètres",
"delete": "Supprimez",
"delete": "Supprimer",
"name": "Nom",
"online": "En ligne",
"offline": "Hors ligne",
"site": "Site",
"dataIn": "Données dans",
"dataOut": "Données épuisées",
"dataIn": "Données reçues",
"dataOut": "Données émises",
"connectionType": "Type de connexion",
"tunnelType": "Type de tunnel",
"local": "Locale",
"edit": "Editer",
"edit": "Modifier",
"siteConfirmDelete": "Confirmer la suppression du site",
"siteDelete": "Supprimer le site",
"siteMessageRemove": "Une fois supprimé, le site ne sera plus accessible. Toutes les ressources et cibles associées au site seront également supprimées.",
@@ -64,11 +64,11 @@
"siteLearnNewt": "Apprenez à installer Newt sur votre système",
"siteSeeConfigOnce": "Vous ne pourrez voir la configuration qu'une seule fois.",
"siteLoadWGConfig": "Chargement de la configuration WireGuard...",
"siteDocker": "Développer les détails du déploiement Docker",
"siteDocker": "Afficher les détails du déploiement Docker",
"toggle": "Activer/désactiver",
"dockerCompose": "Composition Docker",
"dockerRun": "Exécution Docker",
"siteLearnLocal": "Les sites locaux ne tunnel, en savoir plus",
"dockerCompose": "Docker Compose",
"dockerRun": "Docker Run",
"siteLearnLocal": "Les sites locaux ne permettent pas d'utiliser les tunnel, en savoir plus",
"siteConfirmCopy": "J'ai copié la configuration",
"searchSitesProgress": "Rechercher des sites...",
"siteAdd": "Ajouter un site",
@@ -79,9 +79,9 @@
"operatingSystem": "Système d'exploitation",
"commands": "Commandes",
"recommended": "Recommandé",
"siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Il utilise WireGuard sous le capot et vous permet d'adresser vos ressources privées par leur adresse LAN sur votre réseau privé à partir du tableau de bord Pangolin.",
"siteRunsInDocker": "Exécute dans Docker",
"siteRunsInShell": "Exécute en shell sur macOS, Linux et Windows",
"siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Newt se base sur WireGuard et vous permet d'adresser vos ressources privées par leur adresse LAN sur votre réseau privé à partir du tableau de bord Pangolin.",
"siteRunsInDocker": "S'exécute dans Docker",
"siteRunsInShell": "S'exécute en shell sur macOS, Linux et Windows",
"siteErrorDelete": "Erreur lors de la suppression du site",
"siteErrorUpdate": "Impossible de mettre à jour le site",
"siteErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour du site.",
@@ -89,18 +89,18 @@
"siteUpdatedDescription": "Le site a été mis à jour.",
"siteGeneralDescription": "Configurer les paramètres généraux de ce site",
"siteSettingDescription": "Configurer les paramètres de votre site",
"siteSetting": "Réglages {siteName}",
"siteSetting": "Réglages de {siteName}",
"siteNewtTunnel": "Tunnel Newt (Recommandé)",
"siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans votre réseau. Pas de configuration supplémentaire.",
"siteWg": "WireGuard basique",
"siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.",
"siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES",
"siteLocalDescription": "Ressources locales seulement. Pas de tunneling.",
"siteLocalDescriptionSaas": "Local resources only. No tunneling. Only available on remote nodes.",
"siteLocalDescriptionSaas": "Ressources locales seulement. Pas de tunneling. Seulement disponible sur les noeuds distants",
"siteSeeAll": "Voir tous les sites",
"siteTunnelDescription": "Déterminez comment vous voulez vous connecter à votre site",
"siteNewtCredentials": "Identifiants Newt",
"siteNewtCredentialsDescription": "C'est ainsi que Newt s'authentifiera avec le serveur",
"siteNewtCredentialsDescription": "C'est comme cela que Newt s'authentifiera avec le serveur",
"siteCredentialsSave": "Enregistrez vos identifiants",
"siteCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de le copier dans un endroit sécurisé.",
"siteInfo": "Informations sur le site",
@@ -112,7 +112,7 @@
"shareErrorDelete": "Impossible de supprimer le lien",
"shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien",
"shareDeleted": "Lien supprimé",
"shareDeletedDescription": "Le lien a été supprimé",
"shareDeletedDescription": "Le lien de partage a été supprimé",
"shareTokenDescription": "Votre jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.",
"accessToken": "Jeton d'accès",
"usageExamples": "Exemples d'utilisation",
@@ -134,7 +134,7 @@
"shareExpireDescription": "Le temps d'expiration est combien de temps le lien sera utilisable et fournira un accès à la ressource. Après cette période, le lien ne fonctionnera plus et les utilisateurs qui ont utilisé ce lien perdront l'accès à la ressource.",
"shareSeeOnce": "Vous ne pourrez voir ce lien. Assurez-vous de le copier.",
"shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec soin.",
"shareTokenUsage": "Voir Utilisation du jeton d'accès",
"shareTokenUsage": "Voir l'utilisation du jeton d'accès",
"createLink": "Créer un lien",
"resourcesNotFound": "Aucune ressource trouvée",
"resourceSearch": "Rechercher des ressources",
@@ -1234,7 +1234,7 @@
"billing": "Facturation",
"orgBillingDescription": "Gérez vos informations de facturation et vos abonnements",
"github": "GitHub",
"pangolinHosted": "Pangolin Hébergement",
"pangolinHosted": "Hebergé par Pangolin",
"fossorial": "Fossorial",
"completeAccountSetup": "Complétez la configuration du compte",
"completeAccountSetupDescription": "Définissez votre mot de passe pour commencer",
@@ -1280,7 +1280,7 @@
"billingFreeTier": "Niveau gratuit",
"billingWarningOverLimit": "Attention : Vous avez dépassé une ou plusieurs limites d'utilisation. Vos sites ne se connecteront pas tant que vous n'avez pas modifié votre abonnement ou ajusté votre utilisation.",
"billingUsageLimitsOverview": "Vue d'ensemble des limites d'utilisation",
"billingMonitorUsage": "Surveillez votre consommation par rapport aux limites configurées. Si vous avez besoin d'une augmentation des limites, veuillez nous contacter à support@fossorial.io.",
"billingMonitorUsage": "Surveillez votre consommation par rapport aux limites configurées. Si vous avez besoin d'une augmentation des limites, veuillez nous contacter à support@pangolin.net.",
"billingDataUsage": "Utilisation des données",
"billingOnlineTime": "Temps en ligne du site",
"billingUsers": "Utilisateurs actifs",
@@ -1316,7 +1316,7 @@
"billingRemoteExitNodesInfo": "Vous êtes facturé pour chaque nœud géré dans votre organisation. La facturation est calculée quotidiennement en fonction du nombre de nœuds gérés actifs dans votre organisation.",
"domainNotFound": "Domaine introuvable",
"domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.",
"failed": "Échec",
"failed": "Erreur",
"createNewOrgDescription": "Créer une nouvelle organisation",
"organization": "Organisation",
"port": "Port",
@@ -1370,7 +1370,7 @@
"createDomainARecords": "Enregistrements A",
"createDomainRecordNumber": "Enregistrement {number}",
"createDomainTxtRecords": "Enregistrements TXT",
"createDomainSaveTheseRecords": "Enregistrez ces enregistrements",
"createDomainSaveTheseRecords": "Sauvegardez ces enregistrements",
"createDomainSaveTheseRecordsDescription": "Assurez-vous de sauvegarder ces enregistrements DNS car vous ne les reverrez pas.",
"createDomainDnsPropagation": "Propagation DNS",
"createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.",
@@ -1445,7 +1445,7 @@
"IntervalSeconds": "Intervalle sain",
"timeoutSeconds": "Délai",
"timeIsInSeconds": "Le temps est exprimé en secondes",
"retryAttempts": "Tentatives de réessai",
"retryAttempts": "Tentatives",
"expectedResponseCodes": "Codes de réponse attendus",
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
"customHeaders": "En-têtes personnalisés",
@@ -1760,9 +1760,9 @@
"enterpriseEdition": "Enterprise Edition",
"unlicensed": "Unlicensed",
"beta": "Beta",
"manageClients": "Manage Clients",
"manageClients": "Gérer les clients",
"manageClientsDescription": "Clients are devices that can connect to your sites",
"licenseTableValidUntil": "Valid Until",
"licenseTableValidUntil": "Valide jusqu'au",
"saasLicenseKeysSettingsTitle": "Enterprise Licenses",
"saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances",
"sidebarEnterpriseLicenses": "Licenses",
@@ -1771,8 +1771,8 @@
"validation": {
"emailRequired": "Please enter a valid email address",
"useCaseTypeRequired": "Please select a use case type",
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
"firstNameRequired": "Le prénom est requis",
"lastNameRequired": "Le nom est requis",
"primaryUseRequired": "Please describe your primary use",
"jobTitleRequiredBusiness": "Job title is required for business use",
"industryRequiredBusiness": "Industry is required for business use",
@@ -1786,7 +1786,7 @@
},
"useCaseOptions": {
"personal": {
"title": "Personal Use",
"title": "Utilisation personelle",
"description": "For individual, non-commercial use such as learning, personal projects, or experimentation."
},
"business": {
@@ -1819,32 +1819,32 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {
"useCaseQuestion": "Are you using Pangolin for personal or business use?",
"firstName": "First Name",
"lastName": "Last Name",
"jobTitle": "Job Title",
"firstName": "Prénom",
"lastName": "Nom",
"jobTitle": "profession",
"primaryUseQuestion": "What do you primarily plan to use Pangolin for?",
"industryQuestion": "What is your industry?",
"prospectiveUsersQuestion": "How many prospective users do you expect to have?",
"prospectiveSitesQuestion": "How many prospective sites (tunnels) do you expect to have?",
"companyName": "Company name",
"countryOfResidence": "Country of residence",
"stateProvinceRegion": "State / Province / Region",
"postalZipCode": "Postal / ZIP Code",
"companyWebsite": "Company website",
"companyPhoneNumber": "Company phone number",
"country": "Country",
"phoneNumberOptional": "Phone number (optional)",
"companyName": "Entreprise",
"countryOfResidence": "Pays de résidence",
"stateProvinceRegion": "État / Province / Région",
"postalZipCode": "Code postal",
"companyWebsite": "Site de l'entreprise",
"companyPhoneNumber": "Numéro de téléphone professionnel",
"country": "Pays",
"phoneNumberOptional": "Numéro de téléphone (optionnel)",
"complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license."
},
"buttons": {
"close": "Close",
"previous": "Previous",
"next": "Next",
"close": "Fermer",
"previous": "Précédent",
"next": "Suivant",
"generateLicenseKey": "Generate License Key"
},
"toasts": {
@@ -1860,16 +1860,16 @@
},
"priority": "Priorité",
"priorityDescription": "Les routes de haute priorité sont évaluées en premier. La priorité = 100 signifie l'ordre automatique (décision du système). Utilisez un autre nombre pour imposer la priorité manuelle.",
"instanceName": "Instance Name",
"instanceName": "Nom de l'instance",
"pathMatchModalTitle": "Configure Path Matching",
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
"pathMatchType": "Match Type",
"pathMatchPrefix": "Prefix",
"pathMatchPrefix": "Préfix",
"pathMatchExact": "Exact",
"pathMatchRegex": "Regex",
"pathMatchValue": "Path Value",
"clear": "Clear",
"saveChanges": "Save Changes",
"saveChanges": "Sauvegarder",
"pathMatchRegexPlaceholder": "^/api/.*",
"pathMatchDefaultPlaceholder": "/path",
"pathMatchPrefixHelp": "Example: /api matches /api, /api/users, etc.",
@@ -1889,7 +1889,7 @@
"pathRewriteExactHelp": "Replace the entire path with this value when the path matches exactly",
"pathRewriteRegexHelp": "Use capture groups like $1, $2 for replacement",
"pathRewriteStripPrefixHelp": "Leave empty to strip prefix or provide new prefix",
"pathRewritePrefix": "Prefix",
"pathRewritePrefix": "Préfix",
"pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip",

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Piano Gratuito",
"billingWarningOverLimit": "Avviso: Hai superato uno o più limiti di utilizzo. I tuoi siti non si connetteranno finché non modifichi il tuo abbonamento o non adegui il tuo utilizzo.",
"billingUsageLimitsOverview": "Panoramica dei Limiti di Utilizzo",
"billingMonitorUsage": "Monitora il tuo utilizzo rispetto ai limiti configurati. Se hai bisogno di aumentare i limiti, contattaci all'indirizzo support@fossorial.io.",
"billingMonitorUsage": "Monitora il tuo utilizzo rispetto ai limiti configurati. Se hai bisogno di aumentare i limiti, contattaci all'indirizzo support@pangolin.net.",
"billingDataUsage": "Utilizzo dei Dati",
"billingOnlineTime": "Tempo Online del Sito",
"billingUsers": "Utenti Attivi",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "무료 티어",
"billingWarningOverLimit": "경고: 하나 이상의 사용 한도를 초과했습니다. 구독을 수정하거나 사용량을 조정하기 전까지 사이트는 연결되지 않습니다.",
"billingUsageLimitsOverview": "사용 한도 개요",
"billingMonitorUsage": "설정된 한도에 대한 사용량을 모니터링합니다. 한도를 늘려야 하는 경우 support@fossorial.io로 연락하십시오.",
"billingMonitorUsage": "설정된 한도에 대한 사용량을 모니터링합니다. 한도를 늘려야 하는 경우 support@pangolin.net로 연락하십시오.",
"billingDataUsage": "데이터 사용량",
"billingOnlineTime": "사이트 온라인 시간",
"billingUsers": "활성 사용자",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Gratis nivå",
"billingWarningOverLimit": "Advarsel: Du har overskredet en eller flere bruksgrenser. Nettstedene dine vil ikke koble til før du endrer abonnementet ditt eller justerer bruken.",
"billingUsageLimitsOverview": "Oversikt over bruksgrenser",
"billingMonitorUsage": "Overvåk bruken din i forhold til konfigurerte grenser. Hvis du trenger økte grenser, vennligst kontakt support@fossorial.io.",
"billingMonitorUsage": "Overvåk bruken din i forhold til konfigurerte grenser. Hvis du trenger økte grenser, vennligst kontakt support@pangolin.net.",
"billingDataUsage": "Databruk",
"billingOnlineTime": "Online tid for nettsteder",
"billingUsers": "Aktive brukere",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Gratis Niveau",
"billingWarningOverLimit": "Waarschuwing: U hebt een of meer gebruikslimieten overschreden. Uw sites maken geen verbinding totdat u uw abonnement aanpast of uw gebruik aanpast.",
"billingUsageLimitsOverview": "Overzicht gebruikslimieten",
"billingMonitorUsage": "Houd uw gebruik in de gaten ten opzichte van de ingestelde limieten. Als u verhoogde limieten nodig heeft, neem dan contact met ons op support@fossorial.io.",
"billingMonitorUsage": "Houd uw gebruik in de gaten ten opzichte van de ingestelde limieten. Als u verhoogde limieten nodig heeft, neem dan contact met ons op support@pangolin.net.",
"billingDataUsage": "Gegevensgebruik",
"billingOnlineTime": "Site Online Tijd",
"billingUsers": "Actieve Gebruikers",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Darmowy pakiet",
"billingWarningOverLimit": "Ostrzeżenie: Przekroczyłeś jeden lub więcej limitów użytkowania. Twoje witryny nie połączą się, dopóki nie zmienisz subskrypcji lub nie dostosujesz użytkowania.",
"billingUsageLimitsOverview": "Przegląd Limitów Użytkowania",
"billingMonitorUsage": "Monitoruj swoje wykorzystanie w porównaniu do skonfigurowanych limitów. Jeśli potrzebujesz zwiększenia limitów, skontaktuj się z nami pod adresem support@fossorial.io.",
"billingMonitorUsage": "Monitoruj swoje wykorzystanie w porównaniu do skonfigurowanych limitów. Jeśli potrzebujesz zwiększenia limitów, skontaktuj się z nami pod adresem support@pangolin.net.",
"billingDataUsage": "Użycie danych",
"billingOnlineTime": "Czas Online Strony",
"billingUsers": "Aktywni użytkownicy",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Plano Gratuito",
"billingWarningOverLimit": "Aviso: Você ultrapassou um ou mais limites de uso. Seus sites não se conectarão até você modificar sua assinatura ou ajustar seu uso.",
"billingUsageLimitsOverview": "Visão Geral dos Limites de Uso",
"billingMonitorUsage": "Monitore seu uso em relação aos limites configurados. Se precisar aumentar esses limites, entre em contato conosco support@fossorial.io.",
"billingMonitorUsage": "Monitore seu uso em relação aos limites configurados. Se precisar aumentar esses limites, entre em contato conosco support@pangolin.net.",
"billingDataUsage": "Uso de Dados",
"billingOnlineTime": "Tempo Online do Site",
"billingUsers": "Usuários Ativos",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Бесплатный уровень",
"billingWarningOverLimit": "Предупреждение: Вы превысили одну или несколько границ использования. Ваши сайты не подключатся, пока вы не измените подписку или не скорректируете использование.",
"billingUsageLimitsOverview": "Обзор лимитов использования",
"billingMonitorUsage": "Контролируйте использование в соответствии с установленными лимитами. Если вам требуется увеличение лимитов, пожалуйста, свяжитесь с нами support@fossorial.io.",
"billingMonitorUsage": "Контролируйте использование в соответствии с установленными лимитами. Если вам требуется увеличение лимитов, пожалуйста, свяжитесь с нами support@pangolin.net.",
"billingDataUsage": "Использование данных",
"billingOnlineTime": "Время работы сайта",
"billingUsers": "Активные пользователи",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "Ücretsiz Dilim",
"billingWarningOverLimit": "Uyarı: Bir veya daha fazla kullanım limitini aştınız. Aboneliğinizi değiştirmediğiniz veya kullanımı ayarlamadığınız sürece siteleriniz bağlanmayacaktır.",
"billingUsageLimitsOverview": "Kullanım Limitleri Genel Görünümü",
"billingMonitorUsage": "Kullanımınızı yapılandırılmış limitlerle karşılaştırın. Limitlerin artırılmasına ihtiyacınız varsa, lütfen support@fossorial.io adresinden bizimle iletişime geçin.",
"billingMonitorUsage": "Kullanımınızı yapılandırılmış limitlerle karşılaştırın. Limitlerin artırılmasına ihtiyacınız varsa, lütfen support@pangolin.net adresinden bizimle iletişime geçin.",
"billingDataUsage": "Veri Kullanımı",
"billingOnlineTime": "Site Çevrimiçi Süresi",
"billingUsers": "Aktif Kullanıcılar",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

View File

@@ -1280,7 +1280,7 @@
"billingFreeTier": "免费层",
"billingWarningOverLimit": "警告:您已超出一个或多个使用限制。在您修改订阅或调整使用情况之前,您的站点将无法连接。",
"billingUsageLimitsOverview": "使用限制概览",
"billingMonitorUsage": "监控您的使用情况以对比已配置的限制。如需提高限制请联系我们 support@fossorial.io。",
"billingMonitorUsage": "监控您的使用情况以对比已配置的限制。如需提高限制请联系我们 support@pangolin.net。",
"billingDataUsage": "数据使用情况",
"billingOnlineTime": "站点在线时间",
"billingUsers": "活跃用户",
@@ -1819,7 +1819,7 @@
},
"trialPeriodInformation": {
"title": "Trial Period Information",
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@fossorial.io."
"description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net."
}
},
"form": {

4493
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "0.5.6",
"@react-email/components": "0.5.7",
"@react-email/render": "^1.3.2",
"@react-email/tailwind": "1.2.2",
"@simplewebauthn/browser": "^13.2.2",
@@ -77,7 +77,7 @@
"crypto-js": "^4.2.0",
"drizzle-orm": "0.44.6",
"eslint": "9.37.0",
"eslint-config-next": "15.5.4",
"eslint-config-next": "15.5.6",
"express": "5.1.0",
"express-rate-limit": "8.1.0",
"glob": "11.0.3",
@@ -92,7 +92,7 @@
"lucide-react": "^0.545.0",
"maxmind": "5.0.0",
"moment": "2.30.1",
"next": "15.5.4",
"next": "15.5.6",
"next-intl": "^4.3.12",
"next-themes": "0.4.6",
"node-cache": "5.1.2",
@@ -143,13 +143,13 @@
"@types/nodemailer": "7.0.2",
"@types/pg": "8.15.5",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1",
"@types/react-dom": "19.2.2",
"@types/semver": "^7.7.1",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1",
"@types/yargs": "17.0.33",
"drizzle-kit": "0.31.5",
"esbuild": "0.25.10",
"esbuild": "0.25.11",
"esbuild-node-externals": "1.18.0",
"postcss": "^8",
"react-email": "4.3.0",

View File

@@ -126,7 +126,7 @@ export const targets = pgTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").default(100)
});
export const targetHealthCheck = pgTable("targetHealthCheck", {

View File

@@ -138,7 +138,7 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").default(100)
});
export const targetHealthCheck = sqliteTable("targetHealthCheck", {

View File

@@ -88,7 +88,7 @@ export const WelcomeQuickStart = ({
To learn how to use Newt, including more
installation methods, visit the{" "}
<a
href="https://docs.digpangolin.com/manage/sites/install-site"
href="https://docs.pangolin.net/manage/sites/install-site"
className="underline"
>
docs

View File

@@ -89,7 +89,7 @@ export function EmailFooter({ children }: { children: React.ReactNode }) {
<p className="text-xs text-gray-400 mt-4">
For any questions or support, please contact us at:
<br />
support@fossorial.io
support@pangolin.net
</p>
<p className="text-xs text-gray-300 text-center mt-4">
&copy; {new Date().getFullYear()} Fossorial, Inc. All

View File

@@ -19,11 +19,16 @@ import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
async function startServers() {
await setHostMeta();
await config.initServer();
license.setServerSecret(config.getRawConfig().server.secret!);
await license.check();
await runSetupFunctions();
initTelemetryClient();

View File

@@ -612,7 +612,8 @@ export class UsageService {
public async getUsage(
orgId: string,
featureId: FeatureId
featureId: FeatureId,
trx: Transaction | typeof db = db
): Promise<Usage | null> {
if (noop()) {
return null;
@@ -621,7 +622,7 @@ export class UsageService {
const usageId = `${orgId}-${featureId}`;
try {
const [result] = await db
const [result] = await trx
.select()
.from(usage)
.where(eq(usage.usageId, usageId))
@@ -635,7 +636,7 @@ export class UsageService {
const meterId = getFeatureMeterId(featureId);
try {
const [newUsage] = await db
const [newUsage] = await trx
.insert(usage)
.values({
usageId,
@@ -652,7 +653,7 @@ export class UsageService {
return newUsage;
} else {
// Record was created by another process, fetch it
const [existingUsage] = await db
const [existingUsage] = await trx
.select()
.from(usage)
.where(eq(usage.usageId, usageId))
@@ -665,7 +666,7 @@ export class UsageService {
`Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`,
insertError
);
const [existingUsage] = await db
const [existingUsage] = await trx
.select()
.from(usage)
.where(eq(usage.usageId, usageId))
@@ -812,7 +813,8 @@ export class UsageService {
orgId: string,
kickSites = false,
featureId?: FeatureId,
usage?: Usage
usage?: Usage,
trx: Transaction | typeof db = db
): Promise<boolean> {
if (noop()) {
return false;
@@ -825,7 +827,7 @@ export class UsageService {
let orgLimits: Limit[] = [];
if (featureId) {
// Get all limits set for this organization
orgLimits = await db
orgLimits = await trx
.select()
.from(limits)
.where(
@@ -836,7 +838,7 @@ export class UsageService {
);
} else {
// Get all limits set for this organization
orgLimits = await db
orgLimits = await trx
.select()
.from(limits)
.where(eq(limits.orgId, orgId));
@@ -855,7 +857,8 @@ export class UsageService {
} else {
currentUsage = await this.getUsage(
orgId,
limit.featureId as FeatureId
limit.featureId as FeatureId,
trx
);
}
@@ -890,7 +893,7 @@ export class UsageService {
);
// Get all sites for this organization
const orgSites = await db
const orgSites = await trx
.select()
.from(sites)
.where(eq(sites.orgId, orgId));
@@ -902,7 +905,7 @@ export class UsageService {
// Send termination messages to newt sites
for (const site of orgSites) {
if (site.type === "newt") {
const [newt] = await db
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
@@ -917,7 +920,7 @@ export class UsageService {
};
// Don't await to prevent blocking
sendToClient(newt.newtId, payload).catch(
await sendToClient(newt.newtId, payload).catch(
(error: any) => {
logger.error(
`Failed to send termination message to newt ${newt.newtId}:`,

View File

@@ -139,8 +139,8 @@ export async function applyBlueprint(
// password: "sadfasdfadsf",
// "sso-enabled": true,
// "sso-roles": ["Member"],
// "sso-users": ["owen@fossorial.io"],
// "whitelist-users": ["owen@fossorial.io"]
// "sso-users": ["owen@pangolin.net"],
// "whitelist-users": ["owen@pangolin.net"]
// },
// targets: [
// {

View File

@@ -87,8 +87,8 @@ export function convertValue(value: string): any {
// "resources.resource-nice-id.auth.password": "sadfasdfadsf",
// "resources.resource-nice-id.auth.sso-enabled": "true",
// "resources.resource-nice-id.auth.sso-roles[0]": "Member",
// "resources.resource-nice-id.auth.sso-users[0]": "owen@fossorial.io",
// "resources.resource-nice-id.auth.whitelist-users[0]": "owen@fossorial.io",
// "resources.resource-nice-id.auth.sso-users[0]": "owen@pangolin.net",
// "resources.resource-nice-id.auth.whitelist-users[0]": "owen@pangolin.net",
// "resources.resource-nice-id.targets[0].hostname": "localhost",
// "resources.resource-nice-id.targets[0].method": "http",
// "resources.resource-nice-id.targets[0].port": "8000",

View File

@@ -3,11 +3,9 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import { db } from "@server/db";
import { SupporterKey, supporterKey } from "@server/db";
import { eq } from "drizzle-orm";
import { license } from "#dynamic/license/license";
import { configSchema, readConfigFile } from "./readConfigFile";
import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import logger from "@server/logger";
export class Config {
private rawConfig!: z.infer<typeof configSchema>;
@@ -103,16 +101,10 @@ export class Config {
throw new Error("Config not loaded. Call load() first.");
}
license.setServerSecret(this.rawConfig.server.secret!);
await this.checkKeyStatus();
}
private async checkKeyStatus() {
if (build === "enterprise") {
await license.check();
}
if (build == "oss") {
this.checkSupporterKey();
}

View File

@@ -1,4 +1,4 @@
import { db, exitNodes } from "@server/db";
import { db, exitNodes, Transaction } from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm";
@@ -59,7 +59,11 @@ export function selectBestExitNode(
return pingResults[0];
}
export async function checkExitNodeOrg(exitNodeId: number, orgId: string) {
export async function checkExitNodeOrg(
exitNodeId: number,
orgId: string,
trx?: Transaction | typeof db
): Promise<boolean> {
return false;
}

View File

@@ -314,14 +314,11 @@ export const configSchema = z
nameservers: z
.array(z.string().optional().optional())
.optional()
.default(["ns1.fossorial.io", "ns2.fossorial.io"]),
cname_extension: z.string().optional().default("fossorial.io")
.default(["ns1.pangolin.net", "ns2.pangolin.net", "ns3.pangolin.net"]),
cname_extension: z.string().optional().default("cname.pangolin.net")
})
.optional()
.default({
nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"],
cname_extension: "fossorial.io"
})
.default({})
})
.refine(
(data) => {
@@ -392,7 +389,7 @@ export function readConfigFile() {
if (!environment) {
throw new Error(
"No configuration file found. Please create one. https://docs.digpangolin.com/self-host/advanced/config-file"
"No configuration file found. Please create one. https://docs.pangolin.net/self-host/advanced/config-file"
);
}

View File

@@ -33,7 +33,7 @@ class TelemetryClient {
this.client = new PostHog(
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
{
host: "https://digpangolin.com/relay-O7yI"
host: "https://pangolin.net/relay-O7yI"
}
);
@@ -48,11 +48,11 @@ class TelemetryClient {
this.startAnalyticsInterval();
logger.info(
"Pangolin now gathers anonymous usage data to help us better understand how the software is used and guide future improvements and feature development. You can find more details, including instructions for opting out of this anonymous data collection, at: https://docs.digpangolin.com/telemetry"
"Pangolin now gathers anonymous usage data to help us better understand how the software is used and guide future improvements and feature development. You can find more details, including instructions for opting out of this anonymous data collection, at: https://docs.pangolin.net/telemetry"
);
} else if (!this.enabled) {
logger.info(
"Analytics usage statistics collection is disabled. If you enable this, you can help us make Pangolin better for everyone. Learn more at: https://docs.digpangolin.com/telemetry"
"Analytics usage statistics collection is disabled. If you enable this, you can help us make Pangolin better for everyone. Learn more at: https://docs.pangolin.net/telemetry"
);
}
}

View File

@@ -1,5 +1,15 @@
import { db, targetHealthCheck } from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import {
and,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db";
@@ -78,7 +88,7 @@ export async function getTraefikConfig(
and(
eq(targets.enabled, true),
eq(resources.enabled, true),
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)),
eq(sites.exitNodeId, exitNodeId),
or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record

View File

@@ -13,61 +13,21 @@
import config from "./config";
import { certificates, db } from "@server/db";
import { and, eq, isNotNull } from "drizzle-orm";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decryptData } from "@server/lib/encryption";
import * as fs from "fs";
import NodeCache from "node-cache";
import logger from "@server/logger";
export async function getValidCertificatesForDomains(
domains: Set<string>
): Promise<
Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
}>
> {
if (domains.size === 0) {
return [];
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
const domainArray = Array.from(domains);
// TODO: add more foreign keys to make this query more efficient - we dont need to keep getting every certificate
const validCerts = await db
.select({
id: certificates.certId,
domain: certificates.domain,
certFile: certificates.certFile,
keyFile: certificates.keyFile,
expiresAt: certificates.expiresAt,
updatedAt: certificates.updatedAt,
wildcard: certificates.wildcard
})
.from(certificates)
.where(
and(
eq(certificates.status, "valid"),
isNotNull(certificates.certFile),
isNotNull(certificates.keyFile)
)
);
// Filter certificates for the specified domains and if it is a wildcard then you can match on everything up to the first dot
const validCertsFiltered = validCerts.filter((cert) => {
return (
domainArray.includes(cert.domain) ||
(cert.wildcard &&
domainArray.some((domain) =>
domain.endsWith(`.${cert.domain}`)
))
);
});
const encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
@@ -75,10 +35,164 @@ export async function getValidCertificatesForDomains(
);
}
const encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
const validCertsDecrypted = validCertsFiltered.map((cert) => {
// Define the return type for clarity and type safety
export type CertificateResult = {
id: number;
domain: string;
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
};
// --- In-Memory Cache Implementation ---
const certificateCache = new NodeCache({ stdTTL: 180 }); // Cache for 3 minutes (180 seconds)
export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
// 1. Check cache first if enabled
if (useCache) {
for (const domain of domains) {
const cachedCert = certificateCache.get<CertificateResult>(domain);
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
domainsToQuery.add(domain); // Cache miss or expired
}
}
} else {
// If caching is disabled, add all domains to the query set
domains.forEach((d) => domainsToQuery.add(d));
}
// 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults;
}
// 3. Prepare domains for the database query
const domainsToQueryArray = Array.from(domainsToQuery);
const parentDomainsToQuery = new Set<string>();
domainsToQueryArray.forEach((domain) => {
const parts = domain.split(".");
// A wildcard can only match a domain with at least two parts (e.g., example.com)
if (parts.length > 1) {
parentDomainsToQuery.add(parts.slice(1).join("."));
}
});
const parentDomainsArray = Array.from(parentDomainsToQuery);
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
.select()
.from(certificates)
.where(
and(
eq(certificates.status, "valid"),
isNotNull(certificates.certFile),
isNotNull(certificates.keyFile),
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, parentDomainsArray),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
sql`false`
)
)
);
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
wildcardMatches.set(cert.domain, cert);
} else {
exactMatches.set(cert.domain, cert);
}
}
for (const domain of domainsToQuery) {
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
// Priority 1: Check for an exact match (non-wildcard)
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate that matches the exact domain
else {
if (wildcardMatches.has(domain)) {
foundCert = wildcardMatches.get(domain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = domain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {
foundCert = wildcardMatches.get(parentDomain);
}
}
}
}
// If a certificate was found, format it, add to results, and cache it
if (foundCert) {
logger.debug(
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
);
const resultCert: CertificateResult = {
id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record
queriedDomain: domain, // The domain that was originally requested
wildcard: foundCert.wildcard,
certFile: foundCert.certFile,
keyFile: foundCert.keyFile,
expiresAt: foundCert.expiresAt,
updatedAt: foundCert.updatedAt
};
finalResults.push(resultCert);
// Add to cache for future requests, using the *requested domain* as the key
if (useCache) {
certificateCache.set(domain, resultCert);
}
}
}
const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults;
}
function decryptFinalResults(
finalResults: CertificateResult[]
): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decryptData(
cert.certFile!, // is not null from query
@@ -97,4 +211,4 @@ export async function getValidCertificatesForDomains(
});
return validCertsDecrypted;
}
}

View File

@@ -19,7 +19,6 @@ import {
privateConfigSchema,
readPrivateConfigFile
} from "#private/lib/readConfigFile";
import { build } from "@server/build";
export class PrivateConfig {
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
@@ -44,115 +43,104 @@ export class PrivateConfig {
throw new Error(`Invalid private configuration file: ${errors}`);
}
if (parsedPrivateConfig.branding?.colors) {
this.rawPrivateConfig = parsedPrivateConfig;
if (this.rawPrivateConfig.branding?.colors) {
process.env.BRANDING_COLORS = JSON.stringify(
parsedPrivateConfig.branding?.colors
this.rawPrivateConfig.branding?.colors
);
}
if (parsedPrivateConfig.branding?.logo?.light_path) {
if (this.rawPrivateConfig.branding?.logo?.light_path) {
process.env.BRANDING_LOGO_LIGHT_PATH =
parsedPrivateConfig.branding?.logo?.light_path;
this.rawPrivateConfig.branding?.logo?.light_path;
}
if (parsedPrivateConfig.branding?.logo?.dark_path) {
if (this.rawPrivateConfig.branding?.logo?.dark_path) {
process.env.BRANDING_LOGO_DARK_PATH =
parsedPrivateConfig.branding?.logo?.dark_path || undefined;
this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
}
if (build != "oss") {
if (parsedPrivateConfig.branding?.logo?.light_path) {
process.env.BRANDING_LOGO_LIGHT_PATH =
parsedPrivateConfig.branding?.logo?.light_path;
}
if (parsedPrivateConfig.branding?.logo?.dark_path) {
process.env.BRANDING_LOGO_DARK_PATH =
parsedPrivateConfig.branding?.logo?.dark_path || undefined;
}
process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
?.logo?.auth_page?.width
? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
: undefined;
process.env.BRANDING_LOGO_AUTH_HEIGHT = this.rawPrivateConfig.branding
?.logo?.auth_page?.height
? this.rawPrivateConfig.branding?.logo?.auth_page?.height.toString()
: undefined;
process.env.BRANDING_LOGO_AUTH_WIDTH = parsedPrivateConfig.branding
?.logo?.auth_page?.width
? parsedPrivateConfig.branding?.logo?.auth_page?.width.toString()
: undefined;
process.env.BRANDING_LOGO_AUTH_HEIGHT = parsedPrivateConfig.branding
?.logo?.auth_page?.height
? parsedPrivateConfig.branding?.logo?.auth_page?.height.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_WIDTH = this.rawPrivateConfig.branding
?.logo?.navbar?.width
? this.rawPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_HEIGHT = this.rawPrivateConfig.branding
?.logo?.navbar?.height
? this.rawPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_WIDTH = parsedPrivateConfig
.branding?.logo?.navbar?.width
? parsedPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_HEIGHT = parsedPrivateConfig
.branding?.logo?.navbar?.height
? parsedPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined;
process.env.BRANDING_FAVICON_PATH =
this.rawPrivateConfig.branding?.favicon_path;
process.env.BRANDING_FAVICON_PATH =
parsedPrivateConfig.branding?.favicon_path;
process.env.BRANDING_APP_NAME =
this.rawPrivateConfig.branding?.app_name || "Pangolin";
process.env.BRANDING_APP_NAME =
parsedPrivateConfig.branding?.app_name || "Pangolin";
if (parsedPrivateConfig.branding?.footer) {
process.env.BRANDING_FOOTER = JSON.stringify(
parsedPrivateConfig.branding?.footer
);
}
process.env.LOGIN_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.login_page?.title_text || "";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.login_page?.subtitle_text || "";
process.env.SIGNUP_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.signup_page?.title_text || "";
process.env.SIGNUP_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.signup_page?.subtitle_text || "";
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY =
parsedPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO =
parsedPrivateConfig.branding?.resource_auth_page?.show_logo ===
true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.resource_auth_page?.title_text ||
"";
process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.resource_auth_page
?.subtitle_text || "";
if (parsedPrivateConfig.branding?.background_image_path) {
process.env.BACKGROUND_IMAGE_PATH =
parsedPrivateConfig.branding?.background_image_path;
}
if (parsedPrivateConfig.server.reo_client_id) {
process.env.REO_CLIENT_ID =
parsedPrivateConfig.server.reo_client_id;
}
if (parsedPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = parsedPrivateConfig.stripe.s3Bucket;
}
if (parsedPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
parsedPrivateConfig.stripe.localFilePath;
}
if (parsedPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = parsedPrivateConfig.stripe.s3Region;
}
if (parsedPrivateConfig.flags.use_pangolin_dns) {
process.env.USE_PANGOLIN_DNS =
parsedPrivateConfig.flags.use_pangolin_dns.toString();
}
if (this.rawPrivateConfig.branding?.footer) {
process.env.BRANDING_FOOTER = JSON.stringify(
this.rawPrivateConfig.branding?.footer
);
}
this.rawPrivateConfig = parsedPrivateConfig;
process.env.LOGIN_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.title_text || "";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.subtitle_text || "";
process.env.SIGNUP_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.signup_page?.title_text || "";
process.env.SIGNUP_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.signup_page?.subtitle_text || "";
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY =
this.rawPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO =
this.rawPrivateConfig.branding?.resource_auth_page?.show_logo ===
true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.resource_auth_page?.title_text ||
"";
process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.resource_auth_page?.subtitle_text ||
"";
if (this.rawPrivateConfig.branding?.background_image_path) {
process.env.BACKGROUND_IMAGE_PATH =
this.rawPrivateConfig.branding?.background_image_path;
}
if (this.rawPrivateConfig.server.reo_client_id) {
process.env.REO_CLIENT_ID =
this.rawPrivateConfig.server.reo_client_id;
}
if (this.rawPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket;
}
if (this.rawPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
this.rawPrivateConfig.stripe.localFilePath;
}
if (this.rawPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region;
}
if (this.rawPrivateConfig.flags.use_pangolin_dns) {
process.env.USE_PANGOLIN_DNS =
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
}
}
public getRawPrivateConfig() {

View File

@@ -18,7 +18,8 @@ import {
resources,
targets,
sites,
targetHealthCheck
targetHealthCheck,
Transaction
} from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
@@ -183,47 +184,47 @@ export async function listExitNodes(orgId: string, filterOnline = false, noCloud
return [];
}
// Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails
const nodesWithRealOnlineStatus = await Promise.all(
allExitNodes.map(async (node) => {
// If database says it's online, verify with HTTP ping
let online: boolean;
if (filterOnline && node.type == "remoteExitNode") {
try {
const isActuallyOnline = await checkExitNodeOnlineStatus(
node.endpoint
);
// // Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails
// const nodesWithRealOnlineStatus = await Promise.all(
// allExitNodes.map(async (node) => {
// // If database says it's online, verify with HTTP ping
// let online: boolean;
// if (filterOnline && node.type == "remoteExitNode") {
// try {
// const isActuallyOnline = await checkExitNodeOnlineStatus(
// node.endpoint
// );
// set the item in the database if it is offline
if (isActuallyOnline != node.online) {
await db
.update(exitNodes)
.set({ online: isActuallyOnline })
.where(eq(exitNodes.exitNodeId, node.exitNodeId));
}
online = isActuallyOnline;
} catch (error) {
logger.warn(
`Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}`
);
online = false;
}
} else {
online = node.online;
}
// // set the item in the database if it is offline
// if (isActuallyOnline != node.online) {
// await db
// .update(exitNodes)
// .set({ online: isActuallyOnline })
// .where(eq(exitNodes.exitNodeId, node.exitNodeId));
// }
// online = isActuallyOnline;
// } catch (error) {
// logger.warn(
// `Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}`
// );
// online = false;
// }
// } else {
// online = node.online;
// }
return {
...node,
online
};
})
);
// return {
// ...node,
// online
// };
// })
// );
const remoteExitNodes = nodesWithRealOnlineStatus.filter(
const remoteExitNodes = allExitNodes.filter(
(node) =>
node.type === "remoteExitNode" && (!filterOnline || node.online)
);
const gerbilExitNodes = nodesWithRealOnlineStatus.filter(
const gerbilExitNodes = allExitNodes.filter(
(node) => node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
);
@@ -333,8 +334,8 @@ export function selectBestExitNode(
return fallbackNode;
}
export async function checkExitNodeOrg(exitNodeId: number, orgId: string) {
const [exitNodeOrg] = await db
export async function checkExitNodeOrg(exitNodeId: number, orgId: string, trx: Transaction | typeof db = db) {
const [exitNodeOrg] = await trx
.select()
.from(exitNodeOrgs)
.where(

View File

@@ -76,7 +76,7 @@ export const privateConfigSchema = z.object({
local_exit_node_reachable_at: z
.string()
.optional()
.default("http://gerbil:3003")
.default("http://gerbil:3004")
})
.optional()
.default({}),
@@ -172,6 +172,15 @@ export function readPrivateConfigFile() {
return {};
}
// test if the config file is there
if (!fs.existsSync(privateConfigFilePath1)) {
// console.warn(
// `Private configuration file not found at ${privateConfigFilePath1}. Using default configuration.`
// );
// load the default values of the zod schema and return those
return privateConfigSchema.parse({});
}
const loadConfig = (configPath: string) => {
try {
const yamlContent = fs.readFileSync(configPath, "utf8");

View File

@@ -19,13 +19,27 @@ import {
loginPage,
targetHealthCheck
} from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import {
and,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db";
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
import privateConfig from "#private/lib/config";
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
import {
CertificateResult,
getValidCertificatesForDomains
} from "#private/lib/certificates";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
@@ -89,14 +103,11 @@ export async function getTraefikConfig(
subnet: sites.subnet,
exitNodeId: sites.exitNodeId,
// Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Certificate
certificateStatus: certificates.status
domainNamespaceId: domainNamespaces.domainNamespaceId
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.leftJoin(certificates, eq(certificates.domainId, resources.domainId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
@@ -109,10 +120,7 @@ export async function getTraefikConfig(
and(
eq(targets.enabled, true),
eq(resources.enabled, true),
// or(
eq(sites.exitNodeId, exitNodeId),
// isNull(sites.exitNodeId)
// ),
or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
@@ -183,7 +191,6 @@ export async function getTraefikConfig(
tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
certificateStatus: row.certificateStatus,
targets: [],
headers: row.headers,
path: row.path, // the targets will all have the same path
@@ -213,6 +220,20 @@ export async function getTraefikConfig(
});
});
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const resource of resourcesMap.values()) {
if (resource.enabled && resource.ssl && resource.fullDomain) {
domains.add(resource.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
}
const config_output: any = {
http: {
middlewares: {
@@ -255,14 +276,6 @@ export async function getTraefikConfig(
continue;
}
// TODO: for now dont filter it out because if you have multiple domain ids and one is failed it causes all of them to fail
// if (resource.certificateStatus !== "valid" && privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// logger.debug(
// `Resource ${resource.resourceId} has certificate stats ${resource.certificateStats}`
// );
// continue;
// }
// add routers and services empty objects if they don't exist
if (!config_output.http.routers) {
config_output.http.routers = {};
@@ -272,22 +285,22 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
let certResolver: string, preferWildcardCert: boolean;
if (!configDomain) {
certResolver = config.getRawConfig().traefik.cert_resolver;
@@ -310,6 +323,17 @@ export async function getTraefikConfig(
}
: {})
};
} else {
// find a cert that matches the full domain, if not continue
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === resource.fullDomain
);
if (!matchingCert) {
logger.warn(
`No matching certificate found for domain: ${resource.fullDomain}`
);
continue;
}
}
const additionalMiddlewares =
@@ -676,20 +700,31 @@ export async function getTraefikConfig(
loginPageId: loginPage.loginPageId,
fullDomain: loginPage.fullDomain,
exitNodeId: exitNodes.exitNodeId,
domainId: loginPage.domainId,
certificateStatus: certificates.status
domainId: loginPage.domainId
})
.from(loginPage)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, loginPage.exitNodeId)
)
.leftJoin(
certificates,
eq(certificates.domainId, loginPage.domainId)
)
.where(eq(exitNodes.exitNodeId, exitNodeId));
let validCertsLoginPages: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const lp of exitNodeLoginPages) {
if (lp.fullDomain) {
domains.add(lp.fullDomain);
}
}
// get the valid certs for these domains
validCertsLoginPages = await getValidCertificatesForDomains(
domains,
true
); // we are caching here because this is called often
}
if (exitNodeLoginPages.length > 0) {
if (!config_output.http.services) {
config_output.http.services = {};
@@ -719,8 +754,22 @@ export async function getTraefikConfig(
continue;
}
if (lp.certificateStatus !== "valid") {
continue;
let tls = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
// TODO: we need to add the wildcard logic here too
} else {
// find a cert that matches the full domain, if not continue
const matchingCert = validCertsLoginPages.find(
(cert) => cert.queriedDomain === lp.fullDomain
);
if (!matchingCert) {
logger.warn(
`No matching certificate found for login page domain: ${lp.fullDomain}`
);
continue;
}
}
// auth-allowed:
@@ -743,7 +792,7 @@ export async function getTraefikConfig(
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 203,
tls: {}
tls: tls
};
// auth-catchall:
@@ -762,7 +811,7 @@ export async function getTraefikConfig(
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
priority: 202,
tls: {}
tls: tls
};
// we need to add a redirect from http to https too

View File

@@ -61,7 +61,17 @@ export async function createExitNode(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else {
exitNode = exitNodeQuery;
// update the reachable at
[exitNode] = await db
.update(exitNodes)
.set({
reachableAt,
online: true
})
.where(eq(exitNodes.exitNodeId, exitNodeQuery.exitNodeId))
.returning();
logger.info(`Updated exit node reachableAt to ${reachableAt}`);
}
return exitNode;

View File

@@ -292,11 +292,33 @@ hybridRouter.get(
}
);
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get(
"/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => {
try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query
);
@@ -425,20 +447,6 @@ hybridRouter.get(
filtered.push(cert);
}
const encryptionKeyPath =
privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
const encryptionKeyHex = fs
.readFileSync(encryptionKeyPath, "utf8")
.trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
const result = filtered.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decryptData(

View File

@@ -99,7 +99,7 @@ export async function createRemoteExitNode(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Remote exit node limit exceeded. Please upgrade your plan or contact us at support@fossorial.io"
"Remote exit node limit exceeded. Please upgrade your plan or contact us at support@pangolin.net"
)
);
}

View File

@@ -47,12 +47,13 @@ export async function createExitNode(publicKey: string, reachableAt: string | un
.update(exitNodes)
.set({
reachableAt,
publicKey
publicKey,
online: true
})
.where(eq(exitNodes.publicKey, publicKey))
.returning();
logger.info(`Updated exit node`);
logger.info(`Updated exit node with reachableAt to ${reachableAt}`);
}
return exitNode;

View File

@@ -117,27 +117,4 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
};
return configResponse;
}
async function getNextAvailablePort(): Promise<number> {
// Get all existing ports from exitNodes table
const existingPorts = await db
.select({
listenPort: exitNodes.listenPort
})
.from(exitNodes);
// Find the first available port between 1024 and 65535
let nextPort = config.getRawConfig().gerbil.start_port;
for (const port of existingPorts) {
if (port.listenPort > nextPort) {
break;
}
nextPort++;
if (nextPort > 65535) {
throw new Error("No available ports remaining in space");
}
}
return nextPort;
}
}

View File

@@ -98,7 +98,8 @@ export async function updateSiteBandwidth(
if (
await checkExitNodeOrg(
exitNodeId,
updatedSite.orgId
updatedSite.orgId,
trx
)
) {
// not allowed
@@ -148,7 +149,8 @@ export async function updateSiteBandwidth(
orgId,
true,
FeatureId.EGRESS_DATA_MB,
bandwidthUsage
bandwidthUsage,
trx
)
.catch((error: any) => {
logger.error(
@@ -174,7 +176,8 @@ export async function updateSiteBandwidth(
orgId,
true,
FeatureId.SITE_UPTIME,
uptimeUsage
uptimeUsage,
trx
)
.catch((error: any) => {
logger.error(
@@ -242,7 +245,8 @@ export async function updateSiteBandwidth(
if (
await checkExitNodeOrg(
exitNodeId,
updatedSite.orgId
updatedSite.orgId,
trx
)
) {
// not allowed

View File

@@ -414,6 +414,20 @@ authenticated.post(
resource.setResourceWhitelist
);
authenticated.get(
`/resource/:resourceId/whitelist/add`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
resource.addEmailToResourceWhitelist
);
authenticated.get(
`/resource/:resourceId/whitelist/remove`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
resource.removeEmailFromResourceWhitelist
);
authenticated.get(
`/resource/:resourceId/whitelist`,
verifyApiKeyResourceAccess,

View File

@@ -0,0 +1,147 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const addEmailToResourceWhitelistBodySchema = z
.object({
email: z
.string()
.email()
.or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
message:
"Invalid email address. Wildcard (*) must be the entire local part."
})
)
.transform((v) => v.toLowerCase())
})
.strict();
const addEmailToResourceWhitelistParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/whitelist/add",
description: "Add a single email to the resource whitelist.",
tags: [OpenAPITags.Resource],
request: {
params: addEmailToResourceWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: addEmailToResourceWhitelistBodySchema
}
}
}
},
responses: {}
});
export async function addEmailToResourceWhitelist(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addEmailToResourceWhitelistBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email } = parsedBody.data;
const parsedParams = addEmailToResourceWhitelistParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
return response(res, {
data: {},
success: true,
error: false,
message: "Email added to whitelist successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -23,3 +23,5 @@ export * from "./listResourceRules";
export * from "./updateResourceRule";
export * from "./getUserResources";
export * from "./setResourceHeaderAuth";
export * from "./addEmailToResourceWhitelist";
export * from "./removeEmailFromResourceWhitelist";

View File

@@ -0,0 +1,150 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const removeEmailFromResourceWhitelistBodySchema = z
.object({
email: z
.string()
.email()
.or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
message:
"Invalid email address. Wildcard (*) must be the entire local part."
})
)
.transform((v) => v.toLowerCase())
})
.strict();
const removeEmailFromResourceWhitelistParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/whitelist/remove",
description: "Remove a single email from the resource whitelist.",
tags: [OpenAPITags.Resource],
request: {
params: removeEmailFromResourceWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: removeEmailFromResourceWhitelistBodySchema
}
}
}
},
responses: {}
});
export async function removeEmailFromResourceWhitelist(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = removeEmailFromResourceWhitelistBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { email } = parsedBody.data;
const parsedParams =
removeEmailFromResourceWhitelistParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
return response(res, {
data: {},
success: true,
error: false,
message: "Email removed from whitelist successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -17,6 +17,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
const createSiteParamsSchema = z
.object({
@@ -203,10 +204,10 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId);
await db.transaction(async (trx) => {
let newSite: Site;
let newSite: Site;
if ((type == "wireguard" || type == "newt") && exitNodeId) {
await db.transaction(async (trx) => {
if (type == "wireguard" || type == "newt") {
// we are creating a site with an exit node (tunneled)
if (!subnet) {
return next(
@@ -217,11 +218,19 @@ export async function createSite(
);
}
const { exitNode, hasAccess } =
await verifyExitNodeOrgAccess(
exitNodeId,
orgId
if (!exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node ID is required for tunneled sites"
)
);
}
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
exitNodeId,
orgId
);
if (!exitNode) {
logger.warn("Exit node not found");
@@ -257,13 +266,51 @@ export async function createSite(
...(pubKey && type == "wireguard" && { pubKey })
})
.returning();
} else {
// we are creating a site with no tunneling
} else if (type == "local") {
let exitNodeIdToCreate = exitNodeId;
if (!exitNodeIdToCreate) {
if (build == "saas") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node ID of a remote node is required for local sites"
)
);
}
// select the exit node for local sites
// TODO: THIS SHOULD BE CHOSEN IN THE FRONTEND OR SOMETHING BECAUSE
// YOU CAN HAVE MORE THAN ONE NODE IN THE SYSTEM AND YOU SHOULD SELECT
// WHICH GERBIL NODE TO PUT THE SITE ON BUT FOR NOW THIS WILL DO
const [localExitNode] = await trx
.select()
.from(exitNodes)
.where(eq(exitNodes.type, "gerbil"))
.limit(1);
if (!localExitNode) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No gerbil exit node found for organization. Please create a gerbil exit node first."
)
);
}
exitNodeIdToCreate = localExitNode.exitNodeId;
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site type not recognized"
)
);
}
[newSite] = await trx
.insert(sites)
.values({
exitNodeId: exitNodeId,
exitNodeId: exitNodeIdToCreate,
orgId,
name,
niceId,

View File

@@ -46,15 +46,24 @@ const createTargetSchema = z
.optional()
.nullable(),
hcTimeout: z.number().int().positive().min(1).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.number().int().optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000).optional().nullable()
})
.strict();
@@ -164,12 +173,14 @@ export async function createTarget(
let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = [];
let targetIps: string[] = [];
if (site.type == "local") {
newTarget = await db
.insert(targets)
.values({
resourceId,
...targetData
...targetData,
priority: targetData.priority || 100
})
.returning();
} else {
@@ -186,7 +197,7 @@ export async function createTarget(
);
}
const { internalPort, targetIps } = await pickPort(
const { internalPort, targetIps: newTargetIps } = await pickPort(
site.siteId!,
db
);
@@ -214,61 +225,63 @@ export async function createTarget(
pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType,
priority: targetData.priority
})
.returning();
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
priority: targetData.priority || 100
})
.returning();
// add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`);
newTargetIps.push(`${targetData.ip}/32`);
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
targetIps = newTargetIps;
}
await addTargets(
newt.newtId,
newTarget,
healthCheck,
resource.protocol,
resource.proxyPort
);
}
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
})
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await addTargets(
newt.newtId,
newTarget,
healthCheck,
resource.protocol,
resource.proxyPort
);
}
}

View File

@@ -313,6 +313,11 @@ export default async function migration() {
dateCreated: string;
}[];
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials";
`);
for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array(
@@ -325,12 +330,6 @@ export default async function migration() {
)
);
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials"
WHERE "credentialId" = ${webauthnCredential.credentialId}
`);
// Insert the updated record with converted values
await db.execute(sql`
INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated")

View File

@@ -0,0 +1,45 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.11.1";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
// Get the first exit node with type 'gerbil'
const exitNodesQuery = await db.execute(
sql`SELECT "exitNodeId" FROM "exitNodes" WHERE "type" = 'gerbil' LIMIT 1`
);
const exitNodes = exitNodesQuery.rows as {
exitNodeId: number;
}[];
const exitNodeId = exitNodes.length > 0 ? exitNodes[0].exitNodeId : null;
// Get all sites with type 'local'
const sitesQuery = await db.execute(
sql`SELECT "siteId" FROM "sites" WHERE "type" = 'local'`
);
const sites = sitesQuery.rows as {
siteId: number;
}[];
// Update sites to use the exit node
for (const site of sites) {
await db.execute(sql`
UPDATE "sites" SET "exitNodeId" = ${exitNodeId} WHERE "siteId" = ${site.siteId}
`);
}
await db.execute(sql`COMMIT`);
console.log(`Updated sites with exit node`);
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to update sites with exit node");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -77,7 +77,7 @@ export default async function migration() {
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (e) {
console.log(
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.digpangolin.com/self-host/advanced/config-file`
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.pangolin.net/self-host/advanced/config-file`
);
trx.rollback();
return;

View File

@@ -269,6 +269,8 @@ export default async function migration() {
dateCreated: string;
}[];
db.prepare(`DELETE FROM 'webauthnCredentials';`).run();
for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array(
@@ -281,11 +283,6 @@ export default async function migration() {
)
);
// Delete the old record
db.prepare(
`DELETE FROM 'webauthnCredentials' WHERE 'credentialId' = ?`
).run(webauthnCredential.credentialId);
// Insert the updated record with converted values
db.prepare(
`INSERT INTO 'webauthnCredentials' (credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`

View File

@@ -0,0 +1,37 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.11.1";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
db.transaction(() => {
const exitNodes = db.prepare(`SELECT * FROM exitNodes WHERE type = 'gerbil' LIMIT 1`).all() as {
exitNodeId: number;
name: string;
}[];
const exitNodeId = exitNodes.length > 0 ? exitNodes[0].exitNodeId : null;
// get all of the targets
const sites = db.prepare(`SELECT * FROM sites WHERE type = 'local'`).all() as {
siteId: number;
exitNodeId: number | null;
}[];
const defineExitNodeOnSite = db.prepare(
`UPDATE sites SET exitNodeId = ? WHERE siteId = ?`
);
for (const site of sites) {
defineExitNodeOnSite.run(exitNodeId, site.siteId);
}
})();
console.log(`${version} migration complete`);
}

View File

@@ -62,7 +62,7 @@ export default async function migration() {
console.log(`Added new config option: resource_access_token_headers`);
} catch (e) {
console.log(
`Unable to add new config option: resource_access_token_headers. Please add it manually. https://docs.digpangolin.com/self-host/advanced/config-file`
`Unable to add new config option: resource_access_token_headers. Please add it manually. https://docs.pangolin.net/self-host/advanced/config-file`
);
console.error(e);
}

View File

@@ -401,7 +401,7 @@ export default function GeneralPage() {
</Badge>
<Link
className="flex items-center gap-2 text-primary hover:underline"
href="https://digpangolin.com/pricing"
href="https://pangolin.net/pricing"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -270,17 +270,12 @@ export default function ExitNodesTable({
setSelectedNode(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("remoteExitNodeQuestionRemove", {
selectedNode:
selectedNode?.name || selectedNode?.id
})}
{t("remoteExitNodeQuestionRemove")}
</p>
<p>{t("remoteExitNodeMessageRemove")}</p>
<p>{t("remoteExitNodeMessageConfirm")}</p>
</div>
}
buttonText={t("remoteExitNodeConfirmDelete")}

View File

@@ -42,7 +42,7 @@ import {
FaFreebsd,
FaWindows
} from "react-icons/fa";
import {
import {
SiNixos,
SiKubernetes
} from "react-icons/si";
@@ -150,33 +150,33 @@ export default function Page() {
const commands = {
mac: {
"Apple Silicon (arm64)": [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
"Intel x64 (amd64)": [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
linux: {
amd64: [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm64: [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm32: [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm32v6: [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
riscv64: [
`curl -fsSL https://digpangolin.com/get-olm.sh | bash`,
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
@@ -342,14 +342,14 @@ export default function Page() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(
`https://api.github.com/repos/fosrl/olm/releases/latest`,
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
t("olmErrorFetchReleases", {

View File

@@ -168,13 +168,10 @@ export default function GeneralPage() {
}}
dialog={
<div>
<p className="mb-2">
{t("orgQuestionRemove", {
selectedOrg: org?.org.name
})}
<p>
{t("orgQuestionRemove")}
</p>
<p className="mb-2">{t("orgMessageRemove")}</p>
<p>{t("orgMessageConfirm")}</p>
<p>{t("orgMessageRemove")}</p>
</div>
}
buttonText={t("orgDeleteConfirm")}

View File

@@ -135,7 +135,7 @@ const addTargetSchema = z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000)
priority: z.number().int().min(1).max(1000).optional()
})
.refine(
(data) => {
@@ -205,6 +205,7 @@ export default function ReverseProxyTargets(props: {
}) {
const params = use(props.params);
const t = useTranslations();
const { env } = useEnvContext();
const { resource, updateResource } = useResourceContext();
@@ -428,17 +429,19 @@ export default function ReverseProxyTargets(props: {
}, [isAdvancedMode]);
function addNewTarget() {
const isHttp = resource.http;
const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID
ip: "",
method: resource.http ? "http" : null,
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
path: isHttp ? null : null,
pathMatchType: isHttp ? null : null,
rewritePath: isHttp ? null : null,
rewritePathType: isHttp ? null : null,
priority: isHttp ? 100 : 100,
enabled: true,
resourceId: resource.resourceId,
hcEnabled: false,
@@ -514,25 +517,31 @@ export default function ReverseProxyTargets(props: {
try {
setTargetsLoading(true);
const response = await api.post<
AxiosResponse<CreateTargetResponse>
>(`/target`, {
const data: any = {
resourceId: resource.resourceId,
siteId: target.siteId,
ip: target.ip,
method: target.method,
port: target.port,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority,
enabled: target.enabled,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath,
hcInterval: target.hcInterval,
hcTimeout: target.hcTimeout
});
};
// Only include path-related fields for HTTP resources
if (resource.http) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
const response = await api.post<
AxiosResponse<CreateTargetResponse>
>(`/target`, data);
if (response.status === 200) {
// Update the target with the new ID and remove the new flag
@@ -615,19 +624,20 @@ export default function ReverseProxyTargets(props: {
// }
const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = resource.http;
const newTarget: LocalTarget = {
...data,
path: data.path || null,
pathMatchType: data.pathMatchType || null,
rewritePath: data.rewritePath || null,
rewritePathType: data.rewritePathType || null,
path: isHttp ? (data.path || null) : null,
pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: isHttp ? (data.rewritePathType || null) : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
priority: 100,
priority: isHttp ? (data.priority || 100) : 100,
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -666,7 +676,7 @@ export default function ReverseProxyTargets(props: {
...target,
...data,
updated: true,
// siteType: site?.type || null
siteType: site ? site.type : target.siteType
}
: target
)
@@ -719,7 +729,7 @@ export default function ReverseProxyTargets(props: {
// Save targets
for (const target of targets) {
const data = {
const data: any = {
ip: target.ip,
port: target.port,
method: target.method,
@@ -735,14 +745,18 @@ export default function ReverseProxyTargets(props: {
hcHeaders: target.hcHeaders || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcMethod: target.hcMethod || null,
hcStatus: target.hcStatus || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
hcStatus: target.hcStatus || null
};
// Only include path-related fields for HTTP resources
if (resource.http) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
if (target.new) {
const res = await api.put<
AxiosResponse<CreateTargetResponse>
@@ -814,6 +828,7 @@ export default function ReverseProxyTargets(props: {
const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = resource.http;
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
@@ -839,6 +854,7 @@ export default function ReverseProxyTargets(props: {
type="number"
min="1"
max="1000"
onClick={(e) => e.currentTarget.focus()}
defaultValue={row.original.priority || 100}
className="w-full max-w-20"
onBlur={(e) => {
@@ -1007,14 +1023,9 @@ export default function ReverseProxyTargets(props: {
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname
ip: hostname,
...(port && { port: port })
});
if (port) {
updateTarget(row.original.targetId, {
...row.original,
port: port
});
}
};
return (
@@ -1051,12 +1062,12 @@ export default function ReverseProxyTargets(props: {
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm font-medium border-r pr-4 rounded-none h-8 hover:bg-transparent",
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[90px]">
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
@@ -1133,7 +1144,7 @@ export default function ReverseProxyTargets(props: {
<Input
defaultValue={row.original.ip}
placeholder="IP / Hostname"
className="flex-1 min-w-[120px] border-none placeholder-gray-400"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
@@ -1317,15 +1328,20 @@ export default function ReverseProxyTargets(props: {
};
if (isAdvancedMode) {
return [
matchPathColumn,
const columns = [
addressColumn,
rewritePathColumn,
priorityColumn,
healthCheckColumn,
enabledColumn,
actionsColumn
];
// Only include path-related columns for HTTP resources
if (isHttp) {
columns.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn);
}
return columns;
} else {
return [
addressColumn,
@@ -1454,7 +1470,7 @@ export default function ReverseProxyTargets(props: {
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm font-medium"
className="text-sm"
>
{t("advancedMode")}
</label>
@@ -1496,7 +1512,7 @@ export default function ReverseProxyTargets(props: {
className="space-y-4"
id="tls-settings-form"
>
{build == "oss" && (
{!env.flags.usePangolinDns && (
<FormField
control={tlsSettingsForm.control}
name="ssl"

View File

@@ -438,6 +438,7 @@ export default function ResourceRules(props: {
defaultValue={row.original.priority}
className="w-[75px]"
type="number"
onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => {
const parsed = z.coerce
.number()

View File

@@ -25,7 +25,6 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
@@ -58,7 +57,16 @@ import {
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import { ArrowRight, CircleCheck, CircleX, Info, MoveRight, Plus, Settings, SquareArrowOutUpRight } from "lucide-react";
import {
ArrowRight,
CircleCheck,
CircleX,
Info,
MoveRight,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
@@ -89,16 +97,25 @@ import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode';
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "../../../../../components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import { SwitchInput } from "@app/components/SwitchInput";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
http: z.boolean()
@@ -115,54 +132,57 @@ const tcpUdpResourceFormSchema = z.object({
// enableProxy: z.boolean().default(false)
});
const targetsSettingsSchema = z.object({
stickySession: z.boolean()
});
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
}).refine(
(data) => {
// If path is provided, pathMatchType must be provided
if (data.path && !data.pathMatchType) {
return false;
}
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
const addTargetSchema = z
.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000).optional()
})
.refine(
(data) => {
// If path is provided, pathMatchType must be provided
if (data.path && !data.pathMatchType) {
return false;
}
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
message: "Invalid path configuration"
}
return true;
},
{
message: "Invalid path configuration"
}
)
)
.refine(
(data) => {
// If rewritePath is provided, rewritePathType must be provided
@@ -216,12 +236,14 @@ export default function Page() {
>([]);
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null);
const [niceId, setNiceId] = useState<string>("");
// Target management state
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map());
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
@@ -246,17 +268,19 @@ export default function Page() {
}, [isAdvancedMode]);
function addNewTarget() {
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID
ip: "",
method: baseForm.watch("http") ? "http" : null,
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
path: isHttp ? null : null,
pathMatchType: isHttp ? null : null,
rewritePath: isHttp ? null : null,
rewritePathType: isHttp ? null : null,
priority: isHttp ? 100 : 100,
enabled: true,
resourceId: 0,
hcEnabled: false,
@@ -290,12 +314,12 @@ export default function Page() {
...(!env.flags.allowRawResources
? []
: [
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
];
const baseForm = useForm({
@@ -330,26 +354,39 @@ export default function Page() {
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
priority: baseForm.watch("http") ? 100 : undefined
} as z.infer<typeof addTargetSchema>
});
const targetsSettingsForm = useForm({
resolver: zodResolver(targetsSettingsSchema),
defaultValues: {
stickySession: false
}
});
// Helper function to check if all targets have required fields using schema validation
const areAllTargetsValid = () => {
if (targets.length === 0) return true; // No targets is valid
const watchedIp = addTargetForm.watch("ip");
const watchedPort = addTargetForm.watch("port");
const watchedSiteId = addTargetForm.watch("siteId");
return targets.every((target) => {
try {
const isHttp = baseForm.watch("http");
const targetData: any = {
ip: target.ip,
method: target.method,
port: target.port,
siteId: target.siteId,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType
};
const handleContainerSelect = (hostname: string, port?: number) => {
addTargetForm.setValue("ip", hostname);
if (port) {
addTargetForm.setValue("port", port);
}
// Only include priority for HTTP resources
if (isHttp) {
targetData.priority = target.priority;
}
addTargetSchema.parse(targetData);
return true;
} catch {
return false;
}
});
};
const initializeDockerForSite = async (siteId: number) => {
@@ -360,14 +397,14 @@ export default function Page() {
const dockerManager = new DockerManager(api, siteId);
const dockerState = await dockerManager.initializeDocker();
setDockerStates(prev => new Map(prev.set(siteId, dockerState)));
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates(prev => {
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
@@ -378,11 +415,13 @@ export default function Page() {
};
const getDockerStateForSite = (siteId: number): DockerState => {
return dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
};
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
};
async function addTarget(data: z.infer<typeof addTargetSchema>) {
@@ -406,18 +445,20 @@ export default function Page() {
const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = {
...data,
path: data.path || null,
pathMatchType: data.pathMatchType || null,
rewritePath: data.rewritePath || null,
rewritePathType: data.rewritePathType || null,
path: isHttp ? (data.path || null) : null,
pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: isHttp ? (data.rewritePathType || null) : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: 100, // Default priority
priority: isHttp ? (data.priority || 100) : 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -443,7 +484,7 @@ export default function Page() {
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
priority: isHttp ? 100 : undefined
});
}
@@ -463,11 +504,11 @@ export default function Page() {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site?.type || null
}
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
@@ -478,13 +519,11 @@ export default function Page() {
const baseData = baseForm.getValues();
const isHttp = baseData.http;
const stickySessionData = targetsSettingsForm.getValues();
try {
const payload = {
name: baseData.name,
http: baseData.http,
stickySession: stickySessionData.stickySession
};
let sanitizedSubdomain: string | undefined;
@@ -497,7 +536,9 @@ export default function Page() {
: undefined;
Object.assign(payload, {
subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined,
subdomain: sanitizedSubdomain
? toASCII(sanitizedSubdomain)
: undefined,
domainId: httpData.domainId,
protocol: "tcp"
});
@@ -528,13 +569,13 @@ export default function Page() {
if (res && res.status === 201) {
const id = res.data.data.resourceId;
const niceId = res.data.data.niceId;
setResourceId(id);
setNiceId(niceId);
// Create targets if any exist
if (targets.length > 0) {
try {
for (const target of targets) {
const data = {
const data: any = {
ip: target.ip,
port: target.port,
method: target.method,
@@ -551,14 +592,18 @@ export default function Page() {
hcPort: target.hcPort || null,
hcFollowRedirects:
target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
hcStatus: target.hcStatus || null
};
// Only include path-related fields for HTTP resources
if (isHttp) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
await api.put(`/resource/${id}/target`, data);
}
} catch (targetError) {
@@ -660,7 +705,7 @@ export default function Page() {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
// if (domains.length) {
@@ -683,10 +728,10 @@ export default function Page() {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...config,
updated: true
}
...target,
...config,
updated: true
}
: target
)
);
@@ -700,6 +745,7 @@ export default function Page() {
const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = baseForm.watch("http");
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
@@ -712,9 +758,7 @@ export default function Page() {
<Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
{t("priorityDescription")}
</p>
<p>{t("priorityDescription")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -895,31 +939,51 @@ export default function Page() {
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname
ip: hostname,
...(port && { port: port })
});
if (port) {
updateTarget(row.original.targetId, {
...row.original,
port: port
});
}
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input shadow-2xs rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm font-medium border-r pr-4 rounded-none h-8 hover:bg-transparent",
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
<span className="truncate max-w-[90px]">
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
@@ -969,30 +1033,6 @@ export default function Page() {
</Command>
</PopoverContent>
</Popover>
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Select
defaultValue={row.original.method ?? "http"}
@@ -1003,7 +1043,7 @@ export default function Page() {
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
@@ -1020,7 +1060,7 @@ export default function Page() {
<Input
defaultValue={row.original.ip}
placeholder="IP / Hostname"
className="flex-1 min-w-[120px] border-none placeholder-gray-400"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
@@ -1204,15 +1244,20 @@ export default function Page() {
};
if (isAdvancedMode) {
return [
matchPathColumn,
const columns = [
addressColumn,
rewritePathColumn,
priorityColumn,
healthCheckColumn,
enabledColumn,
actionsColumn
];
// Only include path-related columns for HTTP resources
if (isHttp) {
columns.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn);
}
return columns;
} else {
return [
addressColumn,
@@ -1464,10 +1509,10 @@ export default function Page() {
.target
.value
? parseInt(
e
.target
.value
)
e
.target
.value
)
: undefined
)
}
@@ -1546,60 +1591,87 @@ export default function Page() {
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
.map(
(
headerGroup
) => (
<TableRow
key={
headerGroup.id
}
>
{headerGroup.headers.map(
(
header
) => (
<TableHead
key={
cell.id
header.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))
)
)}
</TableHeader>
<TableBody>
{table.getRowModel()
.rows?.length ? (
table
.getRowModel()
.rows.map(
(row) => (
<TableRow
key={
row.id
}
>
{row
.getVisibleCells()
.map(
(
cell
) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
)
)}
</TableRow>
)
)
) : (
<TableRow>
<TableCell
colSpan={columns.length}
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("targetNoOne")}
{t(
"targetNoOne"
)}
</TableCell>
</TableRow>
)}
@@ -1621,12 +1693,16 @@ export default function Page() {
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
checked={
isAdvancedMode
}
onCheckedChange={
setIsAdvancedMode
}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm font-medium"
className="text-sm"
>
{t("advancedMode")}
</label>
@@ -1639,7 +1715,10 @@ export default function Page() {
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
@@ -1677,6 +1756,7 @@ export default function Page() {
}
}}
loading={createLoading}
disabled={!areAllTargetsValid()}
>
{t("resourceCreate")}
</Button>
@@ -1685,24 +1765,36 @@ export default function Page() {
<HealthCheckDialog
open={healthCheckDialogOpen}
setOpen={setHealthCheckDialogOpen}
targetId={selectedTargetForHealthCheck.targetId}
targetId={
selectedTargetForHealthCheck.targetId
}
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
targetMethod={
selectedTargetForHealthCheck.method || undefined
selectedTargetForHealthCheck.method ||
undefined
}
initialConfig={{
hcEnabled:
selectedTargetForHealthCheck.hcEnabled || false,
hcPath: selectedTargetForHealthCheck.hcPath || "/",
selectedTargetForHealthCheck.hcEnabled ||
false,
hcPath:
selectedTargetForHealthCheck.hcPath ||
"/",
hcMethod:
selectedTargetForHealthCheck.hcMethod || "GET",
selectedTargetForHealthCheck.hcMethod ||
"GET",
hcInterval:
selectedTargetForHealthCheck.hcInterval || 5,
hcTimeout: selectedTargetForHealthCheck.hcTimeout || 5,
selectedTargetForHealthCheck.hcInterval ||
5,
hcTimeout:
selectedTargetForHealthCheck.hcTimeout ||
5,
hcHeaders:
selectedTargetForHealthCheck.hcHeaders || undefined,
selectedTargetForHealthCheck.hcHeaders ||
undefined,
hcScheme:
selectedTargetForHealthCheck.hcScheme || undefined,
selectedTargetForHealthCheck.hcScheme ||
undefined,
hcHostname:
selectedTargetForHealthCheck.hcHostname ||
selectedTargetForHealthCheck.ip,
@@ -1713,8 +1805,11 @@ export default function Page() {
selectedTargetForHealthCheck.hcFollowRedirects ||
true,
hcStatus:
selectedTargetForHealthCheck.hcStatus || undefined,
hcMode: selectedTargetForHealthCheck.hcMode || "http",
selectedTargetForHealthCheck.hcStatus ||
undefined,
hcMode:
selectedTargetForHealthCheck.hcMode ||
"http",
hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval ||
30
@@ -1749,7 +1844,9 @@ export default function Page() {
{t("resourceAddEntrypoints")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceAddEntrypointsEditFile")}
{t(
"resourceAddEntrypointsEditFile"
)}
</p>
<CopyTextBox
text={`entryPoints:
@@ -1764,7 +1861,9 @@ export default function Page() {
{t("resourceExposePorts")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceExposePortsEditFile")}
{t(
"resourceExposePortsEditFile"
)}
</p>
<CopyTextBox
text={`ports:
@@ -1775,7 +1874,7 @@ export default function Page() {
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.digpangolin.com/manage/resources/tcp-udp-resources"
href="https://docs.pangolin.net/manage/resources/tcp-udp-resources"
target="_blank"
rel="noopener noreferrer"
>
@@ -1802,7 +1901,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${resourceId}/proxy`
`/${orgId}/settings/resources/${niceId}/proxy`
)
}
>

View File

@@ -43,7 +43,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
await authCookieHeader()
);
resources = res.data.data.resources;
} catch (e) {}
} catch (e) { }
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
@@ -51,7 +51,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
} catch (e) { }
let org = null;
try {
@@ -88,8 +88,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
resource.passwordId !== null ||
resource.whitelist ||
resource.headerAuthId
? "protected"
: "not_protected",
? "protected"
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
ssl: resource.ssl
@@ -128,6 +128,10 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
defaultView={
env.flags.enableClients ? defaultView : "proxy"
}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>

View File

@@ -238,7 +238,7 @@ export default function GeneralPage() {
"enableDockerSocketDescription"
)}{" "}
<Link
href="https://docs.digpangolin.com/manage/sites/configure-site#docker-socket-integration"
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"

View File

@@ -252,43 +252,43 @@ PersistentKeepalive = 5`;
const commands = {
mac: {
All: [
`curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
// "Intel x64 (amd64)": [
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ]
},
linux: {
All: [
`curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
// arm64: [
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ],
// arm32: [
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ],
// arm32v6: [
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ],
// riscv64: [
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ]
},
freebsd: {
All: [
`curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
// arm64: [
// `curl -fsSL https://digpangolin.com/get-newt.sh | bash`,
// `curl -fsSL https://pangolin.net/get-newt.sh | bash`,
// `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ]
},

View File

@@ -326,7 +326,7 @@ export default function PoliciesPage() {
{/*TODO(vlalx): Validate replacing */}
{t('orgPoliciesAboutDescription')}{" "}
<Link
href="https://docs.digpangolin.com/manage/identity-providers/auto-provisioning"
href="https://docs.pangolin.net/manage/identity-providers/auto-provisioning"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"

View File

@@ -315,18 +315,13 @@ export default function LicensePage() {
setSelectedLicenseKey(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("licenseQuestionRemove", {
selectedKey: obfuscateLicenseKey(
selectedLicenseKey.licenseKey
)
})}
{t("licenseQuestionRemove")}
</p>
<p>
<b>{t("licenseMessageRemove")}</b>
</p>
<p>{t("licenseMessageConfirm")}</p>
</div>
}
buttonText={t("licenseKeyDeleteConfirm")}

View File

@@ -237,21 +237,14 @@ export default function UsersTable({ users }: Props) {
setSelected(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("userQuestionRemove", {
selectedUser:
selected?.email ||
selected?.name ||
selected?.username
})}
{t("userQuestionRemove")}
</p>
<p>
<b>{t("userMessageRemove")}</b>
{t("userMessageRemove")}
</p>
<p>{t("userMessageConfirm")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}

View File

@@ -147,7 +147,7 @@ export default async function OrgAuthPage(props: {
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://digpangolin.com/"
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"

View File

@@ -67,6 +67,12 @@ export default async function RootLayout({
)
)();
licenseStatus = licenseStatusRes.data.data;
} else if (build === "saas") {
licenseStatus = {
isHostLicensed: true,
isLicenseValid: true,
hostId: "saas"
};
} else {
licenseStatus = {
isHostLicensed: false,

View File

@@ -193,7 +193,7 @@ export default function IdpTable({ idps }: Props) {
setSelectedIdp(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("idpQuestionRemove", {
name: selectedIdp.name

View File

@@ -256,7 +256,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("userQuestionRemove", {
selectedUser:

View File

@@ -177,19 +177,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
setSelected(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
{t("apiKeysQuestionRemove")}
</p>
<p>
<b>{t("apiKeysMessageRemove")}</b>
{t("apiKeysMessageRemove")}
</p>
<p>{t("apiKeysMessageConfirm")}</p>
</div>
}
buttonText={t("apiKeysDeleteConfirm")}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
@@ -13,6 +14,7 @@ type BrandingLogoProps = {
export default function BrandingLogo(props: BrandingLogoProps) {
const { env } = useEnvContext();
const { theme } = useTheme();
const { isUnlocked } = useLicenseStatusContext();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
@@ -27,12 +29,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
}
if (lightOrDark === "light") {
return (
env.branding.logo?.lightPath || "/logo/word_mark_black.png"
);
if (isUnlocked() && env.branding.logo?.lightPath) {
return env.branding.logo.lightPath;
}
return "/logo/word_mark_black.png";
}
return env.branding.logo?.darkPath || "/logo/word_mark_white.png";
if (isUnlocked() && env.branding.logo?.darkPath) {
return env.branding.logo.darkPath;
}
return "/logo/word_mark_white.png";
}
const path = getPath();

View File

@@ -277,25 +277,12 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
setSelectedClient(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
Are you sure you want to remove the client{" "}
<b>
{selectedClient?.name || selectedClient?.id}
</b>{" "}
from the site and organization?
{t("deleteClientQuestion")}
</p>
<p>
<b>
Once removed, the client will no longer be
able to connect to the site.{" "}
</b>
</p>
<p>
To confirm, please type the name of the client
below.
{t("clientMessageRemove")}
</p>
</div>
}

View File

@@ -44,6 +44,7 @@ import { Description } from "@radix-ui/react-toast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import CopyToClipboard from "./CopyToClipboard";
type InviteUserFormProps = {
open: boolean;
@@ -110,6 +111,17 @@ export default function InviteUserForm({
<CredenzaBody>
<div className="mb-4 break-all overflow-hidden">
{dialog}
<div className="mt-2 mb-6 font-bold text-red-700">
{t("cannotbeUndone")}
</div>
<div>
<div className="flex items-center gap-2">
{t("type")}
<span className="px-2 py-1 rounded-md bg-secondary"><CopyToClipboard text={string} /></span>
{t("toConfirm")}
</div>
</div>
</div>
<Form {...form}>
<form

View File

@@ -16,6 +16,7 @@ import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import BrandingLogo from "@app/components/BrandingLogo";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type DashboardLoginFormProps = {
redirect?: string;
@@ -29,18 +30,22 @@ export default function DashboardLoginForm({
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
function getSubtitle() {
return t("loginStart");
}
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 175 : 175;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 58 : 58;
return (
<Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
height={logoHeight}
width={logoWidth}
/>
</div>
<div className="text-center space-y-1 pt-3">

View File

@@ -237,16 +237,13 @@ export default function DomainsTable({ domains }: Props) {
setSelectedDomain(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("domainQuestionRemove", {
domain: selectedDomain.baseDomain
})}
{t("domainQuestionRemove")}
</p>
<p>
<b>{t("domainMessageRemove")}</b>
{t("domainMessageRemove")}
</p>
<p>{t("domainMessageConfirm")}</p>
</div>
}
buttonText={t("domainConfirmDelete")}

File diff suppressed because it is too large Load Diff

View File

@@ -175,14 +175,11 @@ export default function InvitationsTable({
setSelectedInvitation(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("inviteQuestionRemove", {
email: selectedInvitation?.email || ""
})}
{t("inviteQuestionRemove")}
</p>
<p>{t("inviteMessageRemove")}</p>
<p>{t("inviteMessageConfirm")}</p>
</div>
}
buttonText={t("inviteRemoveConfirm")}

View File

@@ -1,15 +1,13 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -19,6 +17,14 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
: 98;
const logoHeight = isUnlocked()
? env.branding.logo?.navbar?.height || 32
: 32;
useEffect(() => {
function getPath() {
@@ -50,12 +56,8 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
<BrandingLogo
width={
env.branding.logo?.navbar?.width || 98
}
height={
env.branding.logo?.navbar?.height || 32
}
width={logoWidth}
height={logoHeight}
/>
</Link>
{/* {build === "saas" && (

View File

@@ -67,6 +67,9 @@ export function LayoutSidebar({
}, [isSidebarCollapsed]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (!isUnlocked()) {
return undefined;
}
if (env.branding.footer) {
try {
return JSON.parse(env.branding.footer);

View File

@@ -185,19 +185,14 @@ export default function OrgApiKeysTable({
setSelected(null);
}}
dialog={
<div className="space-y-4">
<div>
<p>
{t("apiKeysQuestionRemove", {
selectedApiKey:
selected?.name || selected?.id
})}
{t("apiKeysQuestionRemove")}
</p>
<p>
<b>{t("apiKeysMessageRemove")}</b>
{t("apiKeysMessageRemove")}
</p>
<p>{t("apiKeysMessageConfirm")}</p>
</div>
}
buttonText={t("apiKeysDeleteConfirm")}

View File

@@ -48,6 +48,7 @@ import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
const pinSchema = z.object({
pin: z
@@ -92,6 +93,7 @@ type ResourceAuthPortalProps = {
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter();
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const getNumMethods = () => {
let colLength = 0;
@@ -308,14 +310,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
function getTitle() {
if (build !== "oss" && env.branding.resourceAuthPage?.titleText) {
if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.titleText
) {
return env.branding.resourceAuthPage.titleText;
}
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
if (build !== "oss" && env.branding.resourceAuthPage?.subtitleText) {
if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.subtitleText
) {
return env.branding.resourceAuthPage.subtitleText
.split("{{resourceName}}")
.join(resourceName);
@@ -325,17 +335,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
: t("authenticationRequest", { name: resourceName });
}
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 100 : 100;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 100 : 100;
return (
<div>
{!accessDenied ? (
<div>
{build === "enterprise" ? (
{isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://digpangolin.com/"
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
@@ -350,7 +363,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://digpangolin.com/"
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
@@ -362,18 +375,13 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
)}
<Card>
<CardHeader>
{build !== "oss" &&
{isUnlocked() &&
build !== "oss" &&
env.branding?.resourceAuthPage?.showLogo && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={
env.branding.logo?.authPage
?.height || 100
}
width={
env.branding.logo?.authPage
?.width || 100
}
height={logoHeight}
width={logoWidth}
/>
</div>
)}

View File

@@ -100,6 +100,10 @@ type ResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
defaultView?: "proxy" | "internal";
defaultSort?: {
id: string;
desc: boolean;
};
};
@@ -143,7 +147,8 @@ export default function ResourcesTable({
resources,
internalResources,
orgId,
defaultView = "proxy"
defaultView = "proxy",
defaultSort
}: ResourcesTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -171,12 +176,16 @@ export default function ResourcesTable({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [sites, setSites] = useState<Site[]>([]);
const [proxySorting, setProxySorting] = useState<SortingState>([]);
const [proxySorting, setProxySorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]);
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [internalColumnFilters, setInternalColumnFilters] =
useState<ColumnFiltersState>([]);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
@@ -695,17 +704,12 @@ export default function ResourcesTable({
}}
dialog={
<div>
<p className="mb-2">
{t("resourceQuestionRemove", {
selectedResource:
selectedResource?.name ||
selectedResource?.id
})}
<p>
{t("resourceQuestionRemove")}
</p>
<p>
{t("resourceMessageRemove")}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
@@ -724,17 +728,12 @@ export default function ResourcesTable({
}}
dialog={
<div>
<p className="mb-2">
{t("resourceQuestionRemove", {
selectedResource:
selectedInternalResource?.name ||
selectedInternalResource?.id
})}
<p>
{t("resourceQuestionRemove")}
</p>
<p>
{t("resourceMessageRemove")}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}

View File

@@ -21,6 +21,8 @@ export default function SidebarLicenseButton({
}: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const url = "https://docs.pangolin.net/self-host/enterprise-edition";
const t = useTranslations();
return (
@@ -30,21 +32,21 @@ export default function SidebarLicenseButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href="https://docs.digpangolin.com/">
<Link href={url}>
<Button size="icon" className="w-8 h-8">
<TicketCheck className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Enable Enterprise License
{t("sidebarEnableEnterpriseLicense")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link href="https://docs.digpangolin.com/">
<Link href={url}>
<Button size="sm" className="gap-2 w-full">
Enable Enterprise License
{t("sidebarEnableEnterpriseLicense")}
</Button>
</Link>
)

View File

@@ -18,9 +18,7 @@ import {
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
@@ -31,13 +29,13 @@ import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl";
import BrandingLogo from "@app/components/BrandingLogo";
import { build } from "@server/build";
import { Check, X } from "lucide-react";
import { cn } from "@app/lib/cn";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
// Password strength calculation
const calculatePasswordStrength = (password: string) => {
@@ -111,6 +109,7 @@ export default function SignupForm({
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -192,14 +191,18 @@ export default function SignupForm({
}
};
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
@@ -509,7 +512,7 @@ export default function SignupForm({
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://digpangolin.com/terms-of-service.html"
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
@@ -520,7 +523,7 @@ export default function SignupForm({
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://digpangolin.com/privacy-policy.html"
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"

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