Compare commits

..

138 Commits

Author SHA1 Message Date
Owen
4c1e1daf07 All page types are there and look mostly correct 2026-05-22 17:37:37 -07:00
Owen
7c54df7ed1 Rework page to be functional 2026-05-22 16:09:02 -07:00
Owen
9d77fcc457 Make the first ssh page and conditional http page 2026-05-22 15:12:37 -07:00
Owen
454449ec8a Add support for push pam users 2026-05-22 12:12:55 -07:00
Owen
fe67e8e384 Clean up types 2026-05-22 12:12:46 -07:00
Owen
715b957660 Support not push ssh method 2026-05-22 11:19:35 -07:00
Owen
f1e4bf8d36 Add hint about invoices for the license key 2026-05-22 10:30:41 -07:00
Owen
23ca3efbf4 Merge branch 'dev' into rdp-ssh 2026-05-19 20:12:05 -07:00
Owen
0f9100fd3a Merge branch 'rdp-ssh' of github.com:fosrl/pangolin into rdp-ssh 2026-05-19 20:06:48 -07:00
Owen Schwartz
c47c411161 Merge pull request #3114 from Fredkiss3/fix/tag-input-scroll
fix: make tag input wrap around instead of scrolling
2026-05-19 20:03:59 -07:00
Owen Schwartz
e88e262abe Merge pull request #3004 from Fredkiss3/feat/labels-on-sites-and-resources
feat: site & resource labels
2026-05-19 20:03:22 -07:00
Owen
832d45e32b Move pages back 2026-05-19 20:02:27 -07:00
Owen
69e3ac3cd4 Move login page locations 2026-05-19 20:02:27 -07:00
Owen
50865f4265 Remove terminate button 2026-05-19 20:02:27 -07:00
Owen
0d1a8d9695 Only switch if we are actually connected 2026-05-19 20:02:27 -07:00
Owen
5d8486dd7f Sure up some things with browserAccessType 2026-05-19 20:02:27 -07:00
Owen
3c25932787 Adjust page to be editable 2026-05-19 20:02:27 -07:00
Owen
1d0e1eb126 Temp credential storage 2026-05-19 20:02:27 -07:00
Owen
57c0dc8618 Support private key 2026-05-19 20:02:27 -07:00
Owen
526a147570 Clean up toasts 2026-05-19 20:02:27 -07:00
Owen
0938997548 Add crud for browser targets 2026-05-19 20:02:27 -07:00
Owen
0876b482f8 Remove extra fields 2026-05-19 20:02:27 -07:00
Owen
d558c31f88 Standardize the ui 2026-05-19 20:02:26 -07:00
Owen
6010515da0 Pull in the destination from the api 2026-05-19 20:02:26 -07:00
Owen
868bcd8e34 USe right table 2026-05-19 20:02:26 -07:00
Owen
20c4904965 Add internal api get for proxy information 2026-05-19 20:02:26 -07:00
Owen
5a5536b38c Reinstall packages 2026-05-19 20:02:26 -07:00
Owen
53e2296de8 Clean up forms a bit 2026-05-19 20:02:26 -07:00
Owen
d2423919e9 Add favicon passthrough 2026-05-19 20:02:26 -07:00
Owen
2250fcd177 Serve the resource from the right place 2026-05-19 20:02:26 -07:00
Owen
2a33256d17 Add gateway endpoints into the traefik config 2026-05-19 20:02:26 -07:00
Owen
117aa750f8 Working on new target type 2026-05-19 20:02:26 -07:00
Owen
15f161274f Add browserGatewayTarget table 2026-05-19 20:02:26 -07:00
Owen
09779aca3e Add basic vnc test 2026-05-19 20:02:25 -07:00
Owen
1d1f7cecf4 Support rdp 2026-05-19 20:02:25 -07:00
Owen
dc00668cbe Add first iteration of ssh proxy 2026-05-19 20:02:25 -07:00
Owen
57701e13eb Comment out some fields 2026-05-19 20:02:25 -07:00
Owen
46545cb003 Initial rdp working 2026-05-19 20:02:21 -07:00
Fred KISSIE
6cacc9b83f 💄 limit tag width 2026-05-19 22:52:44 +02:00
Fred KISSIE
1f1791feb7 💄 make tag input wrap around instead of scrolling 2026-05-19 22:48:15 +02:00
Owen
08a08e73b3 derived only from roles that the user holds AND are assigned to the target resource 2026-05-19 10:53:54 -07:00
Fred KISSIE
2d9c082607 💄 UI 2026-05-18 22:17:49 +02:00
Fred KISSIE
7968c4357b edit org label 2026-05-18 22:14:49 +02:00
Fred KISSIE
25c08e7279 Create label dialog 2026-05-18 21:57:44 +02:00
Owen
68e775659b Merge branch 'main' into dev 2026-05-16 20:45:39 -07:00
Owen
1c5e3000b6 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-16 20:45:31 -07:00
Owen
3b93fd99a1 Remove workflows 2026-05-16 20:44:36 -07:00
Owen Schwartz
159e91a07c Merge pull request #3090 from fosrl/github-action-cosign
Upgrade cosign installer to v4.1.2 and pin cosign version
2026-05-16 14:53:24 -07:00
miloschwartz
530b5082bd make online/connected styling consistent 2026-05-16 12:34:17 -07:00
Marc Schäfer
3322f1ccb4 Update cosign installer version in CI workflow 2026-05-16 16:21:13 +02:00
Marc Schäfer
1b17fba19f Upgrade cosign installer to v4.1.2 and pin cosign version
Updated cosign installer to version 4.1.2 and specified cosign release version.
2026-05-16 16:17:45 +02:00
Owen
987b5d580e Sure up some things with browserAccessType 2026-05-15 17:26:58 -07:00
Owen
cb75ffc3b7 Adjust page to be editable 2026-05-15 16:46:43 -07:00
Owen
540f0a754d Temp credential storage 2026-05-15 16:11:23 -07:00
Owen
0f9a6fd968 Support private key 2026-05-15 16:07:14 -07:00
Owen
82112abc34 Clean up toasts 2026-05-15 15:01:37 -07:00
Owen
75b5afd544 Add crud for browser targets 2026-05-15 14:09:33 -07:00
Owen
00e1675f7b Remove extra fields 2026-05-15 12:08:40 -07:00
Owen
2ddbdf977b Standardize the ui 2026-05-15 12:06:05 -07:00
Owen
4c8f0cc9ec Pull in the destination from the api 2026-05-15 11:48:13 -07:00
Owen
e822b681cd Merge branch 'dev' into rdp-ssh 2026-05-15 11:18:31 -07:00
Owen
dd1f7ba544 Make crowdsec --crowdsec 2026-05-14 21:46:26 -07:00
Owen Schwartz
8c2e6965f1 Merge pull request #3081 from fosrl/dev
Update sidebar
2026-05-14 21:21:03 -07:00
Owen
b414f04cce Remove funding 2026-05-14 21:20:34 -07:00
Owen Schwartz
9c71922dda Merge pull request #3079 from fosrl/dev
Add site information to user api
2026-05-14 20:17:19 -07:00
Owen
6e4a28f227 Add site information as well 2026-05-14 18:02:42 -07:00
Owen Schwartz
64d8f035a2 Merge pull request #3077 from fosrl/dev
1.18.4-s.5
2026-05-14 17:41:51 -07:00
Owen
0a5780a3b3 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-14 17:41:00 -07:00
Owen
d58b96f4b1 Add port and icmp information to api endpoint 2026-05-14 17:39:22 -07:00
Owen Schwartz
f778f5c941 Merge pull request #3071 from fosrl/crowdin_dev
New Crowdin updates
2026-05-14 17:20:50 -07:00
Owen
6422208f69 Optimize get all relays 2026-05-14 16:59:15 -07:00
Owen
c3ebc423b5 Each node should only update its own sites 2026-05-14 16:51:09 -07:00
Fred KISSIE
68d7b0a416 🚧 wip: label 2026-05-14 22:43:29 +02:00
Fred KISSIE
43546c84eb 🚧 wip: create label dialog 2026-05-14 22:42:01 +02:00
Fred KISSIE
eac36ee442 delete label 2026-05-14 22:15:43 +02:00
Owen Schwartz
92f992728f Merge pull request #3074 from fosrl/dev
Optimize building aliases in jit mode
2026-05-14 12:25:44 -07:00
Owen
78ad2d17c7 Optimize building aliases in jit mode 2026-05-14 12:25:05 -07:00
Fred KISSIE
9a88394efe 🛂 gate label endpoints behing subscription 2026-05-14 21:17:58 +02:00
Fred KISSIE
173562654b delete org label endpoint 2026-05-14 21:09:48 +02:00
Owen Schwartz
b29bb7384d Merge pull request #3073 from fosrl/dev
Further optimizations
2026-05-14 12:00:25 -07:00
Owen
5a8de8210b Further optimizations 2026-05-14 11:59:59 -07:00
Owen Schwartz
d5181454f4 Merge pull request #3072 from fosrl/dev
Optimize this
2026-05-14 11:34:56 -07:00
Owen
0e0666cacf Optimize this 2026-05-14 11:34:09 -07:00
Owen
e1583a58aa USe right table 2026-05-14 11:33:42 -07:00
Owen Schwartz
02ba2393b9 New translations en-us.json (German)
[ci skip]
2026-05-14 11:08:12 -07:00
Fred KISSIE
8f7e5ab1ed 🚧 wip: org labels page 2026-05-14 19:31:53 +02:00
Fred KISSIE
4334480675 ♻️ refactor 2026-05-14 18:33:29 +02:00
Fred KISSIE
6aa406927a 🐛 fix error message 2026-05-14 18:20:26 +02:00
Fred KISSIE
5b50024712 Merge branch 'dev' into feat/labels-on-sites-and-resources 2026-05-14 18:15:14 +02:00
Owen
7d922ac95f Add internal api get for proxy information 2026-05-13 21:54:58 -07:00
Owen
795a3d351e Reinstall packages 2026-05-13 21:16:40 -07:00
Owen
4b4c86b4b7 Clean up forms a bit 2026-05-13 21:16:00 -07:00
Owen
013af49137 Add favicon passthrough 2026-05-13 21:11:25 -07:00
Owen
a6ae9290f2 Serve the resource from the right place 2026-05-13 18:01:36 -07:00
Owen
de70d72e0d Add gateway endpoints into the traefik config 2026-05-13 17:33:16 -07:00
Owen Schwartz
daf260cf61 Merge pull request #3064 from fosrl/dev
1.18.4-s.1
2026-05-13 14:40:50 -07:00
Owen
92a06e0ea3 Handle jit mode with syncs 2026-05-13 14:00:43 -07:00
Owen
c16d2ff2ed Fix log message 2026-05-13 13:52:35 -07:00
Owen
73a4d7d351 Quiet log message 2026-05-13 11:57:02 -07:00
Owen
4e07e9c52c Working on new target type 2026-05-13 11:56:23 -07:00
Owen
743621eb25 Add browserGatewayTarget table 2026-05-12 21:48:59 -07:00
Owen
943923ff4b Add basic vnc test 2026-05-12 21:12:01 -07:00
Owen
3f17f1a468 Support rdp 2026-05-12 21:12:01 -07:00
Owen
436996a43d Add first iteration of ssh proxy 2026-05-12 21:12:01 -07:00
Owen
d42b6076d2 Comment out some fields 2026-05-12 21:12:01 -07:00
Owen
89cc99f915 Initial rdp working 2026-05-12 21:12:00 -07:00
Fred KISSIE
ce746a2a21 Handle labels for machine clients 2026-05-12 22:32:56 +02:00
Fred KISSIE
7120ab4b22 ♻️ filter sites & resources by labels 2026-05-12 20:45:12 +02:00
Fred KISSIE
12e777b32e Add labels column to private resources table 2026-05-12 20:25:32 +02:00
Fred KISSIE
9378103ddd handle private resources filtering by labels 2026-05-12 20:24:34 +02:00
Fred KISSIE
ec794d5de2 attach/detach private resources 2026-05-12 20:01:33 +02:00
Fred KISSIE
12b18a3e8c attach labels to private resources 2026-05-12 19:58:44 +02:00
Fred KISSIE
91e8a13e59 🗃️ Add site resource labels schema 2026-05-12 17:55:56 +02:00
Fred KISSIE
931ba0f540 💄 px-2 button 2026-05-12 17:46:46 +02:00
Fred KISSIE
d321d7275c 🚧 tried to memo proxy resource table, failed 2026-05-11 21:06:20 +02:00
Fred KISSIE
3855486a00 ️ prevent SitetableCell from rerendering unnecessarily 2026-05-11 19:27:00 +02:00
Fred KISSIE
ab494521b1 labels on proxy resources 2026-05-11 18:37:16 +02:00
Fred KISSIE
549e1ead1d handle labels in resources too 2026-05-11 18:30:23 +02:00
Fred KISSIE
a0759a79a1 🗃️ add unique indexes to site & resource labels in sqlite 2026-05-11 18:28:40 +02:00
Fred KISSIE
14e1a119d3 🚧 WIP: showing labels in proxy resources table 2026-05-11 18:24:47 +02:00
Fred KISSIE
6e066d38b0 🚚 Make label badge its own component 2026-05-11 18:17:29 +02:00
Fred KISSIE
21f72639b6 🚧 make labels column paid, and cleanup 2026-05-11 18:13:19 +02:00
Fred KISSIE
8a0c2031d4 search list by labels too 2026-05-11 18:02:59 +02:00
Fred KISSIE
56d3a466e5 💄 make controlled data table input a search input 2026-05-11 18:02:44 +02:00
Fred KISSIE
563e505cc1 💸 add labels to paid features 2026-05-11 18:02:15 +02:00
Fred KISSIE
c44c02b8ba 💄 make site labels column design nicer 2026-05-11 17:04:44 +02:00
Fred KISSIE
b9ab35a05b 🐛 handle idempotency when adding/removing labels from sites/resources 2026-05-11 16:57:53 +02:00
Fred KISSIE
2fd519e102 add and toggle site labels 2026-05-08 22:31:36 +02:00
Fred KISSIE
a63c1ec364 💄 label selector (with create label) 2026-05-08 21:49:20 +02:00
Fred KISSIE
e61ef2ca2a 🚧 wip: label selector 2026-05-08 20:06:42 +02:00
Fred KISSIE
39b09b7f3f Merge branch 'dev' into feat/labels-on-sites-and-resources 2026-05-08 18:21:46 +02:00
Fred KISSIE
840cc214e3 🚧 wip 2026-05-08 18:21:09 +02:00
Fred KISSIE
72524db52d 💄 shrink button 2026-05-08 02:48:47 +02:00
Fred KISSIE
ab8fc11ab3 🚧 add labels button 2026-05-08 02:46:16 +02:00
Fred KISSIE
1831ca4e75 ♻️ detach label from site/resoirce 2026-05-08 00:33:47 +02:00
Fred KISSIE
0d04cc365f attach label to item 2026-05-05 21:35:10 +02:00
Fred KISSIE
09baf2f32e 🗃️ add sqlite table for labels 2026-05-05 21:08:22 +02:00
Fred KISSIE
3253d60900 🚧 Add CRUD endpoints and tables for labels 2026-05-05 20:53:16 +02:00
107 changed files with 9731 additions and 2171 deletions

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [fosrl]

View File

@@ -415,7 +415,9 @@ jobs:
- name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
- name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.

View File

@@ -1,39 +0,0 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"

View File

@@ -1,160 +0,0 @@
name: SAAS Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
pre-run:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances started"
release-arm:
name: Build and Release (ARM64)
runs-on: [self-hosted, linux, arm64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download MaxMind GeoLite2 databases
env:
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
run: |
echo "Downloading MaxMind GeoLite2 databases..."
# Download GeoLite2-Country
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-Country.tar.gz
# Download GeoLite2-ASN
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-ASN.tar.gz
# Extract the .mmdb files
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
# Verify files exist
if [ ! -f "GeoLite2-Country.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
exit 1
fi
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
exit 1
fi
# Clean up tar files
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
echo "MaxMind databases downloaded successfully"
ls -lh GeoLite2-*.mmdb
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Build and push Docker images (Docker Hub - ARM64)
run: |
TAG=${{ env.TAG }}
make build-saas tag=$TAG
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
shell: bash
post-run:
needs: [pre-run, release-arm]
if: >-
${{
always() &&
needs.pre-run.result == 'success' &&
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
}}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances stopped"

110
cloud Normal file
View File

@@ -0,0 +1,110 @@
git push origin -d 1.11.0-s.0
git push origin -d 1.11.0-s.1
git push origin -d 1.11.0-s.2
git push origin -d 1.11.0-s.3
git push origin -d 1.11.0-s.4
git push origin -d 1.11.0-s.5
git push origin -d 1.11.1-s.0
git push origin -d 1.12.0-s.0
git push origin -d 1.12.2-s.0
git push origin -d 1.12.2-s.1
git push origin -d 1.12.2-s.2
git push origin -d 1.12.2-s.3
git push origin -d 1.12.2-s.4
git push origin -d 1.12.2-s.5
git push origin -d 1.13.0.s.0
git push origin -d 1.13.1-s.0
git push origin -d 1.14.0-s.2
git push origin -d 1.14.1-s.0
git push origin -d 1.14.1-s.1
git push origin -d 1.14.1-s.2
git push origin -d 1.14.1-s.3
git push origin -d 1.15.0-s.0
git push origin -d 1.15.0-s.1
git push origin -d 1.15.0-s.2
git push origin -d 1.15.0-s.3
git push origin -d 1.15.0-s.4
git push origin -d 1.15.0-s.5
git push origin -d 1.15.1-s.0
git push origin -d 1.15.1-s.1
git push origin -d 1.15.3-s.0
git push origin -d 1.15.3-s.1
git push origin -d 1.15.4-s.0
git push origin -d 1.15.4-s.1
git push origin -d 1.15.4-s.10
git push origin -d 1.15.4-s.2
git push origin -d 1.15.4-s.3
git push origin -d 1.15.4-s.4
git push origin -d 1.15.4-s.5
git push origin -d 1.15.4-s.6
git push origin -d 1.15.4-s.7
git push origin -d 1.15.4-s.8
git push origin -d 1.15.4-s.9
git push origin -d 1.16.0-s.0
git push origin -d 1.16.0-s.1
git push origin -d 1.16.1-s.0
git push origin -d 1.16.1-s.1
git push origin -d 1.16.2-s.0
git push origin -d 1.16.2-s.1
git push origin -d 1.16.2-s.10
git push origin -d 1.16.2-s.11
git push origin -d 1.16.2-s.12
git push origin -d 1.16.2-s.13
git push origin -d 1.16.2-s.14
git push origin -d 1.16.2-s.15
git push origin -d 1.16.2-s.16
git push origin -d 1.16.2-s.17
git push origin -d 1.16.2-s.18
git push origin -d 1.16.2-s.19
git push origin -d 1.16.2-s.2
git push origin -d 1.16.2-s.20
git push origin -d 1.16.2-s.21
git push origin -d 1.16.2-s.22
git push origin -d 1.16.2-s.3
git push origin -d 1.16.2-s.4
git push origin -d 1.16.2-s.5
git push origin -d 1.16.2-s.6
git push origin -d 1.16.2-s.7
git push origin -d 1.16.2-s.8
git push origin -d 1.16.2-s.9
git push origin -d 1.17.0-s.0
git push origin -d 1.17.0-s.1
git push origin -d 1.17.0-s.2
git push origin -d 1.17.0-s.3
git push origin -d 1.17.0-s.4
git push origin -d 1.17.1-s.0
git push origin -d 1.17.1-s.1
git push origin -d 1.17.1-s.2
git push origin -d 1.17.1-s.3
git push origin -d 1.17.1-s.4
git push origin -d 1.17.1-s.5
git push origin -d 1.17.1-s.6
git push origin -d 1.17.1-s.7
git push origin -d 1.18.0-s.0
git push origin -d 1.18.0-s.1
git push origin -d 1.18.0-s.2
git push origin -d 1.18.1-s.0
git push origin -d 1.18.1-s.1
git push origin -d 1.18.1-s.2
git push origin -d 1.18.1-s.3
git push origin -d 1.18.1-s.4
git push origin -d 1.18.1-s.5
git push origin -d 1.18.1-s.6
git push origin -d 1.18.1-s.7
git push origin -d 1.18.2-s.0
git push origin -d 1.18.2-s.1
git push origin -d 1.18.2-s.2
git push origin -d 1.18.2-s.3
git push origin -d 1.18.2-s.4
git push origin -d 1.18.2-s.5
git push origin -d 1.18.3-s.0
git push origin -d 1.18.3-s.1
git push origin -d 1.18.3-s.2
git push origin -d 1.18.3-s.3
git push origin -d 1.18.4-s.0
git push origin -d 1.18.4-s.1
git push origin -d 1.18.4-s.2
git push origin -d 1.18.4-s.3
git push origin -d 1.18.4-s.4
git push origin -d 1.18.4-s.5
git push origin -d 1.18.4-s.6

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand"
"embed"
"encoding/base64"
"flag"
"fmt"
"io"
"io/fs"
@@ -68,6 +69,9 @@ const (
func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!")
@@ -206,7 +210,7 @@ func main() {
}
}
if !checkIsCrowdsecInstalledInCompose() {
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) {

View File

@@ -22,11 +22,11 @@
"componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.",
"componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.",
"dismiss": "Verwerfen",
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Standorte, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
"trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.",
"trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.",
"billingTrialBannerTitle": "Kostenlose Testversion aktiv",
"billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Geschäftsstufe. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basisstufe zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.",
"billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Business-Tarif. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basis-Tarif zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.",
"billingTrialBannerUpgrade": "Jetzt upgraden",
"billingTrialBadge": "Kostenlose Testversion",
"trialActive": "Kostenlose Testversion aktiv",
@@ -34,8 +34,8 @@
"trialHasEnded": "Ihre Testversion ist beendet.",
"trialDaysRemaining": "{count, plural, one {# Tag übrig} other {# Tage übrig}}",
"trialDaysLeftShort": "Noch {days}d in der Testversion",
"trialGoToBilling": "Zur Rechnungsseite gehen",
"subscriptionViolationViewBilling": "Rechnung anzeigen",
"trialGoToBilling": "Zur Abrechnung gehen",
"subscriptionViolationViewBilling": "Abrechnung anzeigen",
"componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.",
"componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!",
"inviteErrorNotValid": "Es tut uns leid, aber es sieht so aus, als wäre die Einladung, auf die du zugreifen möchtest, entweder nicht angenommen worden oder nicht mehr gültig.",
@@ -67,7 +67,7 @@
"edit": "Bearbeiten",
"siteConfirmDelete": "Löschen des Standorts bestätigen",
"siteDelete": "Standort löschen",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird sie nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.",
"siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?",
"siteManageSites": "Standorte verwalten",
"siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen",
@@ -117,20 +117,20 @@
"siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren",
"siteSettingDescription": "Standorteinstellungen konfigurieren",
"siteResourcesTab": "Ressourcen",
"siteResourcesNoneOnSite": "Diese Seite hat noch keine öffentlichen oder privaten Ressourcen.",
"siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen",
"siteResourcesSectionPublic": "Öffentliche Ressourcen",
"siteResourcesSectionPrivate": "Private Ressourcen",
"siteResourcesSectionPublicDescription": "Ressourcen, die extern über Domains oder Ports bereitgestellt werden.",
"siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über die Seite verfügbar sind.",
"siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über den Standort verfügbar sind.",
"siteResourcesViewAllPublic": "Alle Ressourcen anzeigen",
"siteResourcesViewAllPrivate": "Alle Ressourcen anzeigen",
"siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit dieser Seite verbunden sind.",
"siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit diesem Standort verbunden sind.",
"siteResourcesShowMore": "Mehr anzeigen",
"siteResourcesPermissionDenied": "Sie haben keine Berechtigung, diese Ressourcen aufzulisten.",
"siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diese Seite vorhanden.",
"siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit dieser Seite verbunden.",
"siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diesen Standort vorhanden.",
"siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit diesem Standort verbunden.",
"siteResourcesHowToAccess": "Zugriffsmöglichkeiten",
"siteResourcesTargetsOnSite": "Ziele auf dieser Seite",
"siteResourcesTargetsOnSite": "Ziele an diesem Standort",
"siteSetting": "{siteName} Einstellungen",
"siteNewtTunnel": "Newt Standort (empfohlen)",
"siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.",
@@ -148,10 +148,10 @@
"siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.",
"siteInfo": "Standortinformationen",
"status": "Status",
"shareTitle": "Links zum Teilen verwalten",
"shareTitle": "Freigabelinks verwalten",
"shareDescription": "Erstelle teilbare Links, um temporären oder permanenten Zugriff auf Proxy-Ressourcen zu gewähren",
"shareSearch": "Freigabe-Links suchen...",
"shareCreate": "Link erstellen",
"shareSearch": "Freigabelinks suchen...",
"shareCreate": "Freigabelink erstellen",
"shareErrorDelete": "Link konnte nicht gelöscht werden",
"shareErrorDeleteMessage": "Fehler beim Löschen des Links",
"shareDeleted": "Link gelöscht",
@@ -161,7 +161,7 @@
"shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?",
"shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.",
"shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.",
"accessToken": "Zugangs-Token",
"accessToken": "Zugriffstoken",
"usageExamples": "Nutzungsbeispiele",
"tokenId": "Token-ID",
"requestHeades": "Anfrage-Header",
@@ -172,12 +172,12 @@
"shareTokenSecurety": "Bewahren Sie das Zugriffstoken sicher. Teilen Sie es nicht in öffentlich zugänglichen Bereichen oder Client-seitigem Code.",
"shareErrorFetchResource": "Fehler beim Abrufen der Ressourcen",
"shareErrorFetchResourceDescription": "Beim Abrufen der Ressourcen ist ein Fehler aufgetreten",
"shareErrorCreate": "Fehler beim Erstellen des Teilen-Links",
"shareErrorCreateDescription": "Beim Erstellen des Teilen-Links ist ein Fehler aufgetreten",
"shareErrorCreate": "Fehler beim Erstellen des Freigabelinks",
"shareErrorCreateDescription": "Beim Erstellen des Freigabelinks ist ein Fehler aufgetreten",
"shareCreateDescription": "Jeder mit diesem Link kann auf die Ressource zugreifen",
"shareTitleOptional": "Titel (optional)",
"expireIn": "Verfällt in",
"neverExpire": "Nie ablaufen",
"expireIn": "Läuft ab in",
"neverExpire": "Läuft nie ab",
"shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.",
"shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.",
"shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.",
@@ -186,7 +186,7 @@
"resourcesNotFound": "Keine Ressourcen gefunden",
"resourceSearch": "Suche Ressourcen",
"machineSearch": "Maschinen suchen",
"machinesSearch": "Suche Maschinen-Klienten...",
"machinesSearch": "Maschinen-Clients suchen",
"machineNotFound": "Keine Maschinen gefunden",
"userDeviceSearch": "Benutzergeräte durchsuchen",
"userDevicesSearch": "Benutzergeräte durchsuchen...",
@@ -203,7 +203,7 @@
"proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
"clientResourceTitle": "Private Ressourcen verwalten",
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
"privateResourcesBannerTitle": "Zero-Trust Privater Zugang",
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
"privateResourcesBannerDescription": "Private Ressourcen nutzen Zero-Trust und stellen sicher, dass Benutzer und Maschinen nur auf Ressourcen zugreifen können, die Sie explizit gewähren. Verbinden Sie Benutzergeräte oder Maschinen-Clients, um auf diese Ressourcen über ein sicheres virtuelles privates Netzwerk zuzugreifen.",
"resourcesSearch": "Suche Ressourcen...",
"resourceAdd": "Ressource hinzufügen",
@@ -265,7 +265,7 @@
"rules": "Regeln",
"resourceSettingDescription": "Einstellungen für die Ressource konfigurieren",
"resourceSetting": "{resourceName} Einstellungen",
"alwaysAllow": "Auth umgehen",
"alwaysAllow": "Authentifizierung umgehen",
"alwaysDeny": "Zugriff blockieren",
"passToAuth": "Weiterleiten zur Authentifizierung",
"orgSettingsDescription": "Organisationseinstellungen konfigurieren",
@@ -274,7 +274,7 @@
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
"saveSettings": "Einstellungen speichern",
"orgDangerZone": "Gefahrenzone",
"orgDangerZoneDescription": "Sobald Sie diesen Org löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.",
"orgDangerZoneDescription": "Sobald Sie diese Organisation löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.",
"orgDelete": "Organisation löschen",
"orgDeleteConfirm": "Organisation löschen bestätigen",
"orgMessageRemove": "Diese Aktion ist unwiderruflich und löscht alle zugehörigen Daten.",
@@ -323,7 +323,7 @@
"accessApprovalsManage": "Genehmigungen verwalten",
"accessApprovalsDescription": "Zeige und verwalte ausstehende Genehmigungen für den Zugriff auf diese Organisation",
"description": "Beschreibung",
"inviteTitle": "Einladungen öffnen",
"inviteTitle": "Offene Einladungen",
"inviteDescription": "Einladungen für andere Benutzer verwalten, der Organisation beizutreten",
"inviteSearch": "Einladungen suchen...",
"minutes": "Minuten",
@@ -370,12 +370,12 @@
"apiKeysDescription": "API-Schlüssel werden zur Authentifizierung mit der Integrations-API verwendet",
"provisioningKeysTitle": "Bereitstellungsschlüssel",
"provisioningKeysManage": "Bereitstellungsschlüssel verwalten",
"provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Seiten für Ihr Unternehmen zu authentifizieren.",
"provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Standorten für Ihr Unternehmen zu authentifizieren.",
"provisioningManage": "Bereitstellung",
"provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Seiten prüfen, die noch auf Genehmigung warten.",
"pendingSites": "Ausstehende Seiten",
"siteApproveSuccess": "Site erfolgreich freigegeben",
"siteApproveError": "Fehler beim Bestätigen der Seite",
"provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Standorte prüfen, die noch auf Genehmigung warten.",
"pendingSites": "Ausstehende Standorte",
"siteApproveSuccess": "Standort erfolgreich freigegeben",
"siteApproveError": "Fehler beim Genehmigen des Standorts",
"provisioningKeys": "Bereitstellungsschlüssel",
"searchProvisioningKeys": "Bereitstellungsschlüssel suchen...",
"provisioningKeysAdd": "Bereitstellungsschlüssel generieren",
@@ -405,7 +405,7 @@
"provisioningKeysNeverUsed": "Nie",
"provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten",
"provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.",
"provisioningKeysApproveNewSites": "Neue Seiten genehmigen",
"provisioningKeysApproveNewSites": "Neuen Standort genehmigen",
"provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.",
"provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels",
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
@@ -413,8 +413,8 @@
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.",
"provisioningKeysBannerButtonText": "Mehr erfahren",
"pendingSitesBannerTitle": "Ausstehende Seiten",
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
"pendingSitesBannerTitle": "Ausstehende Standorte",
"pendingSitesBannerDescription": "Standorte, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
"pendingSitesBannerButtonText": "Mehr erfahren",
"apiKeysSettings": "{apiKeyName} Einstellungen",
"userTitle": "Alle Benutzer verwalten",
@@ -461,7 +461,7 @@
"licenseActivateKeyDescription": "Geben Sie einen Lizenzschlüssel ein, um ihn zu aktivieren.",
"licenseActivate": "Lizenz aktivieren",
"licenseAgreement": "Durch Ankreuzung dieses Kästchens bestätigen Sie, dass Sie die Lizenzbedingungen gelesen und akzeptiert haben, die mit dem Lizenzschlüssel in Verbindung stehen.",
"fossorialLicense": "Fossorial Gewerbelizenz & Abonnementbedingungen anzeigen",
"fossorialLicense": "Kommerzielle Fossorial-Lizenz und Abonnementbedingungen anzeigen",
"licenseMessageRemove": "Dadurch werden der Lizenzschlüssel und alle zugehörigen Berechtigungen entfernt.",
"licenseMessageConfirm": "Um zu bestätigen, geben Sie bitte den Lizenzschlüssel unten ein.",
"licenseQuestionRemove": "Sind Sie sicher, dass Sie den Lizenzschlüssel löschen möchten?",
@@ -481,7 +481,7 @@
"licensePurchaseSites": "Zusätzliche Standorte kaufen\n",
"licenseSitesUsedMax": "{usedSites} von {maxSites} Standorten verwendet",
"licenseSitesUsed": "{count, plural, =0 {# Standorte} one {# Standort} other {# Standorte}} im System.",
"licensePurchaseDescription": "Wähle aus, für wieviele Seiten du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Seiten hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}",
"licensePurchaseDescription": "Wähle aus, für wie viele Standorte du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Standorte hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}",
"licenseFee": "Lizenzgebühr",
"licensePriceSite": "Preis pro Standort",
"total": "Gesamt",
@@ -532,7 +532,7 @@
"userRemoveOrgConfirmSelf": "Entfernung bestätigen",
"userRemoveOrgSelf": "Sich selbst aus der Organisation entfernen",
"userRemoveOrgSelfWarning": "Sie verlieren sofort den Zugriff auf diese Organisation.",
"userRemoveOrgConfirmPhraseSelf": "ENTFERNUNG MICH SELBST AUS DER ORGANISATION",
"userRemoveOrgConfirmPhraseSelf": "MICH SELBST AUS DER ORGANISATION ENTFERNEN",
"users": "Benutzer",
"accessRoleMember": "Mitglied",
"accessRoleOwner": "Eigentümer",
@@ -1711,11 +1711,11 @@
"regionSelectorComingSoon": "Kommt bald",
"billingLoadingSubscription": "Abonnement wird geladen...",
"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.",
"billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Standorte 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@pangolin.net.",
"billingDataUsage": "Datenverbrauch",
"billingSites": "Seiten",
"billingSites": "Standorte",
"billingUsers": "Benutzergeräte",
"billingDomains": "Domänen",
"billingOrganizations": "Orden",
@@ -1743,7 +1743,7 @@
"billingCheckoutError": "Checkout-Fehler",
"billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL",
"billingPortalError": "Portalfehler",
"billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Websites ein. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.",
"billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Standorte ein. Wenn Sie Ihr Limit erreichen, werden Ihre Standorte die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.",
"billingSInfo": "Anzahl der Sites die Sie verwenden können",
"billingUsersInfo": "Wie viele Benutzer Sie verwenden können",
"billingDomainInfo": "Wie viele Domains Sie verwenden können",
@@ -1927,7 +1927,7 @@
"configureHealthCheck": "Gesundheits-Check konfigurieren",
"configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein",
"enableHealthChecks": "Gesundheits-Checks aktivieren",
"healthCheckDisabledStateDescription": "Wenn deaktiviert, führt die Seite keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.",
"healthCheckDisabledStateDescription": "Wenn deaktiviert, führt der Standort keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.",
"enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.",
"healthScheme": "Methode",
"healthSelectScheme": "Methode auswählen",
@@ -2187,8 +2187,8 @@
}
},
"remoteExitNodeSelection": "Knotenauswahl",
"remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diese lokale Seite geleitet werden soll",
"remoteExitNodeRequired": "Ein Knoten muss für lokale Seiten ausgewählt sein",
"remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diesen lokalen Standort geleitet werden soll",
"remoteExitNodeRequired": "Ein Knoten muss für lokale Standorte ausgewählt sein",
"noRemoteExitNodesAvailable": "Keine Knoten verfügbar",
"noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Standorte zu verwenden.",
"exitNode": "Exit-Node",
@@ -3235,7 +3235,7 @@
"uptimeAddAlert": "Warnmeldung hinzufügen",
"uptimeViewAlerts": "Warnungen anzeigen",
"uptimeCreateEmailAlert": "E-Mail Alarm erstellen",
"uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn diese Seite offline oder wieder online ist.",
"uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn dieser Standort offline oder wieder online ist.",
"uptimeAlertDescriptionResource": "Werde per E-Mail benachrichtigt, wenn diese Ressource offline oder wieder online ist.",
"uptimeAlertNamePlaceholder": "Alarmname",
"uptimeAdditionalEmails": "Zusätzliche E-Mails",

View File

@@ -255,6 +255,23 @@
"resourceGoTo": "Go to Resource",
"resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource",
"labelDelete": "Delete Label",
"labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully",
"labelEditSuccessMessage": "Label Modified Successfully",
"labelNameField": "Label Name",
"labelColorField": "Label Color",
"labelPlaceholder": "Ex: homelab",
"labelCreate": "Create Label",
"createLabelDialogTitle": "Create Label",
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
"labelEdit": "Edit Label",
"editLabelDialogTitle": "Update Label",
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
"labelDeleteConfirm": "Confirm Delete Label",
"labelErrorDelete": "Failed to delete label",
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
"visibility": "Visibility",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -1140,6 +1157,15 @@
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found",
"inviteInvalid": "Invalid Invite",
"labels": "Labels",
"orgLabelsDescription": "Manage labels in this organization.",
"addLabels": "Add labels",
"siteLabelsTab": "Labels",
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelSearch": "Search labels",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user",
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
@@ -1846,6 +1872,7 @@
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
"billingCurrentKeys": "Current Keys",
"billingModifyCurrentPlan": "Modify Current Plan",
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
"billingConfirmUpgrade": "Confirm Upgrade",
"billingConfirmDowngrade": "Confirm Downgrade",
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
@@ -1943,6 +1970,36 @@
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings",
"rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings",
"sshServer": "SSH Server",
"rdpServer": "RDP Server",
"vncServer": "VNC Server",
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
"rdpServerDescription": "Configure the destination and port of the RDP server",
"vncServerDescription": "Configure the destination and port of the VNC server",
"sshServerMode": "Mode",
"sshServerModeStandard": "Standard SSH Server",
"sshServerModePangolin": "Pangolin SSH",
"sshServerModeStandardDescription": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.",
"sshServerModeNative": "Native SSH Server",
"sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.",
"sshAuthenticationMethod": "Authentication Method",
"sshAuthMethodManual": "Manual Authentication",
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
"sshAuthMethodAutomated": "Automated Provisioning",
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
"sshAuthDaemonLocation": "Auth Daemon Location",
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
"sshDaemonLocationRemote": "On Remote Host",
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
"sshDaemonPort": "Daemon Port",
"sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"destination": "Destination",
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
"sshAccess": "SSH Access",
"roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow",
@@ -2937,7 +2994,7 @@
"learnMore": "Learn more",
"backToHome": "Go back to home",
"needToSignInToOrg": "Need to use your organization's identity provider?",
"maintenanceMode": "Maintenance Mode",
"maintenanceMode": "Maintenance Page",
"maintenanceModeDescription": "Display a maintenance page to visitors",
"maintenanceModeType": "Maintenance Mode Type",
"showMaintenancePage": "Show a maintenance page to visitors",
@@ -2967,6 +3024,7 @@
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
"createInternalResourceDialogDestinationRequired": "Destination is required",
"available": "Available",
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
"archived": "Archived",
"noArchivedDevices": "No archived devices found",
"deviceArchived": "Device archived",

View File

@@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
reactStrictMode: false,
transpilePackages: ["@novnc/novnc"],
eslint: {
ignoreDuringBuilds: true
},

102
package-lock.json generated
View File

@@ -11,11 +11,14 @@
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -44,6 +47,9 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",
@@ -1058,7 +1064,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1460,6 +1465,16 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@devolutions/iron-remote-desktop": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
},
"node_modules/@devolutions/iron-remote-desktop-rdp": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
@@ -2354,7 +2369,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2377,7 +2391,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2400,7 +2413,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2417,7 +2429,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2434,7 +2445,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2451,7 +2461,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2468,7 +2477,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2485,7 +2493,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2502,7 +2509,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2519,7 +2525,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2536,7 +2541,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2553,7 +2557,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2576,7 +2579,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2599,7 +2601,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2622,7 +2623,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2645,7 +2645,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2668,7 +2667,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2691,7 +2689,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2714,7 +2711,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -2734,7 +2730,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2754,7 +2749,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2774,7 +2768,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3034,7 +3027,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -3654,6 +3646,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oslojs/asn1": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
@@ -6981,7 +6979,6 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -8442,7 +8439,6 @@
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -8558,7 +8554,6 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -8906,7 +8901,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -9002,7 +8996,6 @@
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -9030,7 +9023,6 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -9056,7 +9048,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9067,7 +9058,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -9154,7 +9144,8 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -9228,7 +9219,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -9683,6 +9673,27 @@
"win32"
]
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -9702,7 +9713,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10152,7 +10162,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -10224,7 +10233,6 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -10353,7 +10361,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -11260,7 +11267,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -11701,6 +11707,7 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -12335,7 +12342,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -12421,7 +12427,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -12558,7 +12563,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -12952,7 +12956,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -15370,6 +15373,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -15380,6 +15384,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -15468,7 +15473,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
@@ -16428,7 +16432,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -16936,7 +16939,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16968,7 +16970,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -17261,7 +17262,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -18723,8 +18723,7 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -19199,7 +19198,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19627,7 +19625,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -19834,7 +19831,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -34,11 +34,14 @@
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -67,6 +70,9 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",

View File

@@ -148,11 +148,22 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule",
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks"
listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets"
}
export async function checkUserActionPermission(

View File

@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: varchar("authToken").notNull(),
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
destination: varchar("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -147,7 +147,6 @@ export const resources = pgTable("resources", {
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull()
.default(false),
@@ -159,9 +158,100 @@ export const resources = pgTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false)
wildcard: boolean("wildcard").notNull().default(false),
browserAccessType: text("browserAccessType").default("http"), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
color: varchar("color").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = pgTable(
"siteLabels",
{
siteLabelId: serial("siteLabelId").primaryKey(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = pgTable(
"resourceLabels",
{
resourceLabelId: serial("resourceLabelId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = pgTable(
"siteResourceLabels",
{
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = pgTable(
"clientLabels",
{
clientLabelId: serial("clientLabelId").primaryKey(),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = pgTable("targets", {
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
@@ -196,9 +286,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"),
@@ -266,8 +358,11 @@ export const siteResources = pgTable("siteResources", {
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">()
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
@@ -1097,19 +1192,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false)
});
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const statusHistory = pgTable(
"statusHistory",
{
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull()
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1179,3 +1285,4 @@ export type RoundTripMessageTracker = InferSelectModel<
>;
export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: text("authToken").notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -180,9 +180,106 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
browserAccessType: text("browserAccessType").default("http"), // rdp, ssh, http, vnc
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
@@ -219,9 +316,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -295,8 +394,11 @@ export const siteResources = sqliteTable("siteResources", {
.notNull()
.default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">()
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
@@ -1196,19 +1298,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1278,3 +1391,4 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;

View File

@@ -24,10 +24,12 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain"
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],

View File

@@ -20,9 +20,7 @@ import {
} from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import {
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
import {
initPeerAddHandshake,
deletePeer as olmDeletePeer
@@ -33,7 +31,7 @@ import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargetV2,
parseEndpoint,
parseEndpoint
} from "@server/lib/ip";
import {
addPeerData,
@@ -51,10 +49,7 @@ export async function getClientSiteResourceAccess(
? await trx
.select()
.from(sites)
.innerJoin(
siteNetworks,
eq(siteNetworks.siteId, sites.siteId)
)
.innerJoin(siteNetworks, eq(siteNetworks.siteId, sites.siteId))
.where(eq(siteNetworks.networkId, siteResource.networkId))
.then((rows) => rows.map((row) => row.sites))
: [];
@@ -362,7 +357,8 @@ export async function rebuildClientAssociationsFromSiteResource(
.where(inArray(clients.clientId, existingClientSiteIds))
: [];
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
@@ -709,7 +705,7 @@ export async function updateClientSiteDestinations(
sourcePort: destination.sourcePort,
destinations: destination.destinations
};
logger.info(
logger.debug(
`Payload for update-destinations: ${JSON.stringify(payload, null, 2)}`
);

View File

@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
}
}
logger.debug(
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
);
// logger.debug(
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
// );
for (const domain of allDomains) {
try {

View File

@@ -24,7 +24,8 @@ import { LogStreamingManager } from "./LogStreamingManager";
*/
export const logStreamingManager = new LogStreamingManager();
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
if (build !== "saas") {
// this is handled separately in the saas build, so we don't want to start it here
logStreamingManager.start();
}

View File

@@ -12,6 +12,7 @@
*/
import {
browserGatewayTarget,
certificates,
db,
domainNamespaces,
@@ -277,6 +278,115 @@ export async function getTraefikConfig(
});
});
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Browser gateway target fields
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
// Group browser gateway targets by resource
type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
targets: {
browserGatewayTargetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
siteExitNodeId: number | null;
}[];
};
const browserGatewayResourcesMap = new Map<
number,
BrowserGatewayResourceEntry
>();
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
targets: []
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
@@ -324,6 +434,12 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain);
}
}
// Include browser gateway resource domains
for (const bgResource of browserGatewayResourcesMap.values()) {
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
domains.add(bgResource.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)}`);
@@ -589,7 +705,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
@@ -925,6 +1041,185 @@ export async function getTraefikConfig(
}
}
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
);
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
@@ -1040,7 +1335,7 @@ export async function getTraefikConfig(
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};
@@ -1143,7 +1438,7 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.https_entrypoint
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 203,
tls: tls
};

View File

@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
next: NextFunction
): Promise<any> {
try {
if (build != "saas") {
if (build !== "saas") {
return next();
}

View File

@@ -0,0 +1,187 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
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 { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive(),
type: z.enum(["ssh", "rdp", "vnc"]),
destination: z.string().nonempty(),
destinationPort: z.number().int().min(1).max(65535)
});
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "put",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
description: "Create a browser gateway target for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found in organization ${orgId}`
)
);
}
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
const [record] = await db
.insert(browserGatewayTarget)
.values({
resourceId,
siteId,
type,
destination,
destinationPort,
authToken: encryptedToken
})
.returning();
if (site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[record],
newt.version
);
}
}
logger.info(
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
);
return response<CreateBrowserGatewayTargetResponse>(res, {
data: record,
success: true,
error: false,
message: "Browser gateway target created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create browser gateway target"
)
);
}
}

View File

@@ -0,0 +1,130 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db, newts, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
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 { OpenAPITags, registry } from "@server/openApi";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Delete a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
await db
.delete(browserGatewayTarget)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
);
if (existing.site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.bgt.siteId))
.limit(1);
if (newt) {
await removeBrowserGatewayTarget(
newt.newtId,
browserGatewayTargetId,
newt.version
);
}
}
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
return response(res, {
data: null,
success: true,
error: false,
message: "Browser gateway target deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete browser gateway target"
)
);
}
}

View File

@@ -0,0 +1,109 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
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 { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "get",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Get a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function getBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [result] = await db
.select({ bgt: browserGatewayTarget })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!result) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
return response<GetBrowserGatewayTargetResponse>(res, {
data: result.bgt,
success: true,
error: false,
message: "Browser gateway target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve browser gateway target"
)
);
}
}

View File

@@ -0,0 +1,18 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createBrowserGatewayTarget";
export * from "./updateBrowserGatewayTarget";
export * from "./deleteBrowserGatewayTarget";
export * from "./getBrowserGatewayTarget";
export * from "./listBrowserGatewayTargets";

View File

@@ -0,0 +1,148 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
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 { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const querySchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export type ListBrowserGatewayTargetsResponse = {
targets: BrowserGatewayTarget[];
total: number;
limit: number;
offset: number;
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
description: "List browser gateway targets for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function listBrowserGatewayTargets(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const targets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.resourceId, resourceId))
.limit(limit)
.offset(offset);
return response<ListBrowserGatewayTargetsResponse>(res, {
data: {
targets: targets,
total: targets.length,
limit,
offset
},
success: true,
error: false,
message: "Browser gateway targets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to list browser gateway targets"
)
);
}
}

View File

@@ -0,0 +1,180 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
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 { OpenAPITags, registry } from "@server/openApi";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive().optional(),
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
destination: z.string().nonempty().optional(),
destinationPort: z.number().int().min(1).max(65535).optional()
});
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "post",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Update a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
const updateValues: Partial<BrowserGatewayTarget> = {};
if (siteId !== undefined) updateValues.siteId = siteId;
if (type !== undefined) updateValues.type = type;
if (destination !== undefined) updateValues.destination = destination;
if (destinationPort !== undefined)
updateValues.destinationPort = destinationPort;
const [updated] = await db
.update(browserGatewayTarget)
.set(updateValues)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
)
.returning();
const targetSiteId = siteId ?? existing.bgt.siteId;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, targetSiteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, targetSiteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[updated],
newt.version
);
}
}
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
return response<UpdateBrowserGatewayTargetResponse>(res, {
data: updated,
success: true,
error: false,
message: "Browser gateway target updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update browser gateway target"
)
);
}
}

View File

@@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels";
import {
verifyOrgAccess,
@@ -732,6 +734,59 @@ authenticated.get(
alertRule.getAlertRule
);
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get(
"/org/:orgId/health-checks",
verifyValidLicense,
@@ -775,3 +830,48 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getTarget),
healthChecks.getHealthCheckStatusHistory
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import {
verifyApiKeyHasAction,
@@ -215,3 +216,43 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -0,0 +1,224 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const attachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function attachLabelToItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = attachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteLabels)
.values({
labelId,
siteId
})
.onConflictDoNothing();
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(resourceLabels)
.values({
labelId,
resourceId
})
.onConflictDoNothing();
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(siteResourceLabels)
.values({
labelId,
siteResourceId
})
.onConflictDoNothing();
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
// idempotent, calling this endpoint multiple times should attach the label only once
await db
.insert(clientLabels)
.values({
labelId,
clientId
})
.onConflictDoNothing();
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label attached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,149 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
db,
labels,
resourceLabels,
resources,
siteLabels,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty(),
siteId: z.number().int().optional(),
resourceId: z.number().int().optional()
});
export async function createOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { name, color, siteId, resourceId } = parsedBody.data;
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Site with Id ${siteId} doesn't exist.`
)
);
}
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
}
const label = await db.transaction(async (tx) => {
const [label] = await tx
.insert(labels)
.values({
name,
color,
orgId
})
.returning();
if (siteId) {
await tx.insert(siteLabels).values({
siteId,
labelId: label.labelId
});
}
if (resourceId) {
await tx.insert(resourceLabels).values({
resourceId,
labelId: label.labelId
});
}
return label;
});
return response<CreateOrEditLabelResponse>(res, {
data: { label },
success: true,
error: false,
message: "Org Label created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,72 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
export async function deleteOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
await db
.delete(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
return response(res, {
data: null,
success: true,
error: false,
message: "Label deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,224 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import {
clients,
clientLabels,
db,
labels,
resourceLabels,
resources,
siteLabels,
siteResourceLabels,
siteResources,
sites
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const detachLabelBodySchema = z.strictObject({
siteId: z.number().int().optional(),
resourceId: z.number().int().optional(),
siteResourceId: z.number().int().optional(),
clientId: z.number().int().optional()
});
export async function detachLabelFromItem(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = detachLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, resourceId, siteResourceId, clientId } =
parsedBody.data;
if (!siteId && !resourceId && !siteResourceId && !clientId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Label with Id ${labelId} not found`
)
);
}
if (siteId) {
const siteCount = await db.$count(
sites,
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
);
if (siteCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with Id ${siteId} doesn't exist.`
)
);
}
await db
.delete(siteLabels)
.where(
and(
eq(siteLabels.labelId, labelId),
eq(siteLabels.siteId, siteId)
)
);
}
if (resourceId) {
const resourceCount = await db.$count(
resources,
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with Id ${resourceId} doesn't exist.`
)
);
}
await db
.delete(resourceLabels)
.where(
and(
eq(resourceLabels.labelId, labelId),
eq(resourceLabels.resourceId, resourceId)
)
);
}
if (siteResourceId) {
const resourceCount = await db.$count(
siteResources,
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
);
if (resourceCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`SiteResource with Id ${siteResourceId} doesn't exist.`
)
);
}
await db
.delete(siteResourceLabels)
.where(
and(
eq(siteResourceLabels.labelId, labelId),
eq(siteResourceLabels.siteResourceId, siteResourceId)
)
);
}
if (clientId) {
const clientCount = await db.$count(
clients,
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
);
if (clientCount === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with Id ${clientId} doesn't exist.`
)
);
}
await db
.delete(clientLabels)
.where(
and(
eq(clientLabels.labelId, labelId),
eq(clientLabels.clientId, clientId)
)
);
}
return response(res, {
data: {},
success: true,
error: false,
message: "Label detached successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,19 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";

View File

@@ -0,0 +1,155 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, like, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listLabelsSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
function queryLabelsBase() {
return db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(labels);
}
export async function listOrgLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listLabelsSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [and(eq(labels.orgId, orgId))];
if (query) {
conditions.push(
like(
sql`LOWER(${labels.name})`,
"%" + query.toLowerCase() + "%"
)
);
}
const baseQuery = queryLabelsBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
queryLabelsBase()
.where(and(...conditions))
.as("filtered_labels")
);
const labelListQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(labels.name));
const [totalCount, rows] = await Promise.all([
countQuery,
labelListQuery
]);
return response<ListOrgLabelsResponse>(res, {
data: {
labels: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,101 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db, labels } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
labelId: z.string().transform(Number).pipe(z.int().positive())
});
const updateLabelBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export async function updateOrgLabel(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, labelId } = parsedParams.data;
const parsedBody = updateLabelBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const [existing] = await db
.select()
.from(labels)
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
if (!existing) {
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
}
const { name, color } = parsedBody.data;
const [label] = await db
.update(labels)
.set({
name,
color
})
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
.returning();
return response<CreateOrEditLabelResponse>(res, {
data: {
label
},
success: true,
error: false,
message: "Label updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -19,10 +19,12 @@ import {
logsDb,
newts,
roles,
roleSiteResources,
roundTripMessageTracker,
siteResources,
siteNetworks,
userOrgs
userOrgs,
sites
} from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
@@ -47,7 +49,8 @@ const bodySchema = z
.strictObject({
publicKey: z.string().nonempty(),
resourceId: z.number().int().positive().optional(),
resource: z.string().nonempty().optional() // this is either the nice id or the alias
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
username: z.string().nonempty().optional()
})
.refine(
(data) => {
@@ -62,19 +65,19 @@ const bodySchema = z
);
export type SignSshKeyResponse = {
certificate: string;
certificate?: string;
messageIds: number[];
messageId: number;
messageId?: number;
sshUsername: string;
sshHost: string;
resourceId: number;
siteIds: number[];
siteId: number;
keyId: string;
validPrincipals: string[];
validAfter: string;
validBefore: string;
expiresIn: number;
keyId?: string;
validPrincipals?: string[];
validAfter?: string;
validBefore?: string;
expiresIn?: number;
};
// registry.registerPath({
@@ -125,7 +128,8 @@ export async function signSshKey(
const {
publicKey,
resourceId,
resource: resourceQueryString
resource: resourceQueryString,
username
} = parsedBody.data;
const userId = req.user?.userId;
const roleIds = req.userOrgRoleIds ?? [];
@@ -173,101 +177,6 @@ export async function signSshKey(
);
}
let usernameToUse;
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
usernameToUse = req.user?.email
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Unable to extract username from email"
)
);
}
} else if (req.user?.username) {
usernameToUse = req.user.username;
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Username is not valid for SSH certificate"
)
);
}
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User does not have a valid email or username for SSH certificate"
)
);
}
// prefix with p-
usernameToUse = `p-${usernameToUse}`;
// check if we have a existing user in this org with the same
const [existingUserWithSameName] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, usernameToUse)
)
)
.limit(1);
if (existingUserWithSameName) {
let foundUniqueUsername = false;
for (let attempt = 0; attempt < 20; attempt++) {
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
const candidateUsername = `${usernameToUse}${randomNum}`;
const [existingUser] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, candidateUsername)
)
)
.limit(1);
if (!existingUser) {
usernameToUse = candidateUsername;
foundUniqueUsername = true;
break;
}
}
if (!foundUniqueUsername) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Unable to generate a unique username for SSH certificate"
)
);
}
}
await db
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
);
} else {
usernameToUse = userOrg.pamUsername;
}
// Get and decrypt the org's CA keys
const caKeys = await getOrgCAKeys(
orgId,
@@ -360,69 +269,303 @@ export async function signSshKey(
);
}
const roleRows = await db
.select()
.from(roles)
.where(inArray(roles.roleId, roleIds));
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
let homedir: boolean | null = null;
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
let sudoMode: "none" | "commands" | "full" = "none";
for (const roleRow of roleRows) {
try {
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
} catch {
// skip
}
try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
sudoMode = m as "none" | "commands" | "full";
}
}
const parsedGroups = Array.from(parsedGroupsSet);
if (homedir === null && roleRows.length > 0) {
homedir = roleRows[0].sshCreateHomeDir ?? null;
}
const sites = await db
const sitesFromNetworks = await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId!));
const siteIds = sites.map((site) => site.siteId);
const siteIds = sitesFromNetworks.map((site) => site.siteId);
// Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000));
// only valid for 5 minutes
const validFor = 300n;
let expiresIn: number | undefined;
let messageIds: number[] = [];
let cert:
| {
certificate: string;
keyId: string;
validPrincipals: string[];
validAfter: Date;
validBefore: Date;
}
| undefined;
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
// if the mode is passthrough then just use what was provided because the user will log in themselves
let usernameToUse;
if (resource.pamMode === "push") {
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
usernameToUse = req.user?.email
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Unable to extract username from email"
)
);
}
} else if (req.user?.username) {
usernameToUse = req.user.username;
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
usernameToUse = usernameToUse.replace(
/[^a-zA-Z0-9_-]/g,
"-"
);
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Username is not valid for SSH certificate"
)
);
}
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User does not have a valid email or username for SSH certificate"
)
);
}
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
keyId: `${usernameToUse}@${resource.niceId}`,
validPrincipals: [usernameToUse, resource.niceId],
validAfter: now - 60n, // Start 1 min ago for clock skew
validBefore: now + validFor
});
// prefix with p-
usernameToUse = `p-${usernameToUse}`;
// check if we have a existing user in this org with the same
const [existingUserWithSameName] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, usernameToUse)
)
)
.limit(1);
if (existingUserWithSameName) {
let foundUniqueUsername = false;
for (let attempt = 0; attempt < 20; attempt++) {
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
const candidateUsername = `${usernameToUse}${randomNum}`;
const [existingUser] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, candidateUsername)
)
)
.limit(1);
if (!existingUser) {
usernameToUse = candidateUsername;
foundUniqueUsername = true;
break;
}
}
if (!foundUniqueUsername) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Unable to generate a unique username for SSH certificate"
)
);
}
}
await db
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
);
} else {
usernameToUse = userOrg.pamUsername;
}
const roleRows = await db
.select({
sshSudoCommands: roles.sshSudoCommands,
sshUnixGroups: roles.sshUnixGroups,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshSudoMode: roles.sshSudoMode
})
.from(roles)
.innerJoin(
roleSiteResources,
eq(roleSiteResources.roleId, roles.roleId)
)
.where(
and(
inArray(roles.roleId, roleIds),
eq(
roleSiteResources.siteResourceId,
resource.siteResourceId
)
)
);
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
let homedir: boolean | null = null;
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
let sudoMode: "none" | "commands" | "full" = "none";
for (const roleRow of roleRows) {
try {
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
} catch {
// skip
}
try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps))
grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (
sudoModeOrder[m as keyof typeof sudoModeOrder] >
sudoModeOrder[sudoMode]
) {
sudoMode = m as "none" | "commands" | "full";
}
}
const parsedGroups = Array.from(parsedGroupsSet);
if (homedir === null && roleRows.length > 0) {
homedir = roleRows[0].sshCreateHomeDir ?? null;
}
// Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000));
// only valid for 5 minutes
const validFor = 300n;
expiresIn = Number(validFor); // seconds
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
keyId: `${usernameToUse}@${resource.niceId}`,
validPrincipals: [usernameToUse, resource.niceId],
validAfter: now - 60n, // Start 1 min ago for clock skew
validBefore: now + validFor
});
const messageIds: number[] = [];
for (const siteId of siteIds) {
// get the site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
)
);
}
messageIds.push(message.messageId);
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
externalAuthDaemon:
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
}
} else if (resource.pamMode === "passthrough") {
usernameToUse = username;
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Username must be provided when PAM mode is passthrough"
)
);
}
} else {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Invalid PAM mode configured for resource"
)
);
}
let sshHost: string | undefined;
if (
resource.authDaemonMode === "site" ||
resource.authDaemonMode === "remote"
) {
if (resource.alias && resource.alias != "") {
sshHost = resource.alias;
} else {
sshHost = resource.destination;
}
} else if (resource.authDaemonMode === "native") {
if (siteIds.length > 1) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Multiple sites associated with resource, unable to determine SSH host when in native mode"
)
);
}
const messageIds: number[] = [];
for (const siteId of siteIds) {
// get the site
const [newt] = await db
const [site] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.from(sites)
.where(eq(sites.siteId, siteIds[0]))
.limit(1);
if (!newt) {
if (!site) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -431,54 +574,26 @@ export async function signSshKey(
);
}
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
if (!site.address) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
"Site address not configured, unable to determine SSH host when in native mode"
)
);
}
messageIds.push(message.messageId);
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
// its the address but split off the cidr if there is one
sshHost = site.address.split("/")[0];
}
const expiresIn = Number(validFor); // seconds
let sshHost;
if (resource.alias && resource.alias != "") {
sshHost = resource.alias;
} else {
sshHost = resource.destination;
if (!sshHost) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Unable to determine SSH host for the resource"
)
);
}
await logsDb.insert(actionAuditLog).values({
@@ -505,7 +620,7 @@ export async function signSshKey(
: undefined,
metadata: {
resourceName: resource.name,
siteId: siteIds[0],
siteIds: siteIds,
sshUsername: usernameToUse,
sshHost: sshHost
},
@@ -515,18 +630,18 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, {
data: {
certificate: cert.certificate,
certificate: cert?.certificate,
messageIds: messageIds,
messageId: messageIds[0], // just pick the first one for backward compatibility
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
sshUsername: usernameToUse,
sshHost: sshHost,
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
resourceId: resource.siteResourceId,
siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId,
validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(),
validBefore: cert.validBefore.toISOString(),
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
keyId: cert?.keyId,
validPrincipals: cert?.validPrincipals,
validAfter: cert?.validAfter.toISOString(),
validBefore: cert?.validBefore.toISOString(),
expiresIn
},
success: true,

View File

@@ -1,15 +1,20 @@
import {
clientLabels,
clients,
clientSitesAssociationsCache,
currentFingerprint,
db,
labels,
olms,
orgs,
roleClients,
sites,
userClients,
users
users,
type Label
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -169,6 +174,7 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
siteNiceId: string | null;
}>;
olmUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
};
type OlmWithUpdateAvailable = ClientWithSites;
@@ -255,6 +261,11 @@ export async function listClients(
(client) => client.clientId
);
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter
const conditions = [
and(
@@ -288,18 +299,29 @@ export async function listClients(
}
if (query) {
conditions.push(
or(
like(
sql`LOWER(${clients.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${clients.niceId})`,
"%" + query.toLowerCase() + "%"
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${clients.name})`, q),
like(sql`LOWER(${clients.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
)
);
);
}
conditions.push(or(...queryList));
}
const baseQuery = queryClientsBase().where(and(...conditions));
@@ -326,6 +348,30 @@ export async function listClients(
const clientIds = clientsList.map((client) => client.clientId);
const siteAssociations = await getSiteAssociations(clientIds);
let labelsForClients: Array<{
labelId: number;
name: string;
color: string;
clientId: number;
}> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) {
labelsForClients = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
clientId: clientLabels.clientId
})
.from(labels)
.innerJoin(
clientLabels,
eq(clientLabels.labelId, labels.labelId)
)
.where(inArray(clientLabels.clientId, clientIds))
.orderBy(asc(clientLabels.clientLabelId));
}
// Group site associations by client ID
const sitesByClient = siteAssociations.reduce(
(acc, association) => {
@@ -353,7 +399,10 @@ export async function listClients(
const clientsWithSites = clientsList.map((client) => {
return {
...client,
sites: sitesByClient[client.clientId] || []
sites: sitesByClient[client.clientId] || [],
labels: labelsForClients.filter(
(l) => l.clientId === client.clientId
)
};
});

View File

@@ -11,7 +11,7 @@ import {
ExitNode
} from "@server/db";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
@@ -97,86 +97,119 @@ export async function generateRelayMappings(exitNode: ExitNode) {
return {};
}
// Filter to sites with the required fields up front so the rest of the
// function can safely treat endpoint/subnet/listenPort as defined.
const validSites = sitesRes.filter(
(s) => s.endpoint && s.subnet && s.listenPort
);
if (validSites.length === 0) {
return {};
}
const siteIds = validSites.map((s) => s.siteId);
const orgIds = Array.from(
new Set(
validSites
.map((s) => s.orgId)
.filter((id): id is NonNullable<typeof id> => id != null)
)
);
// Batch fetch all client-site associations for these sites in one query.
const clientSitesRes = siteIds.length
? await db
.select()
.from(clientSitesAssociationsCache)
.where(inArray(clientSitesAssociationsCache.siteId, siteIds))
: [];
// Batch fetch all sites in the relevant orgs in one query (covers
// site-to-site communication for every site processed below).
const orgSitesRes = orgIds.length
? await db.select().from(sites).where(inArray(sites.orgId, orgIds))
: [];
// Index org sites by orgId for O(1) lookup per site.
const sitesByOrg = new Map<string, typeof orgSitesRes>();
for (const peer of orgSitesRes) {
if (
peer.orgId == null ||
!peer.endpoint ||
!peer.subnet ||
!peer.listenPort
) {
continue;
}
let arr = sitesByOrg.get(peer.orgId);
if (!arr) {
arr = [];
sitesByOrg.set(peer.orgId, arr);
}
arr.push(peer);
}
// Index client-site associations by siteId for O(1) lookup per site.
const clientSitesBySite = new Map<number, typeof clientSitesRes>();
for (const cs of clientSitesRes) {
let arr = clientSitesBySite.get(cs.siteId);
if (!arr) {
arr = [];
clientSitesBySite.set(cs.siteId, arr);
}
arr.push(cs);
}
// Initialize mappings object for multi-peer support
const mappings: { [key: string]: ProxyMapping } = {};
// Process each site
for (const site of sitesRes) {
if (!site.endpoint || !site.subnet || !site.listenPort) {
continue;
// Track destinations per endpoint to deduplicate in O(1).
const seen = new Map<string, Set<string>>();
const addDestination = (endpoint: string, dest: PeerDestination) => {
let destSet = seen.get(endpoint);
if (!destSet) {
destSet = new Set();
seen.set(endpoint, destSet);
mappings[endpoint] = { destinations: [] };
}
// Find all clients associated with this site through clientSites
const clientSitesRes = await db
.select()
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, site.siteId));
for (const clientSite of clientSitesRes) {
if (!clientSite.endpoint) {
continue;
}
// Add this site as a destination for the client
if (!mappings[clientSite.endpoint]) {
mappings[clientSite.endpoint] = { destinations: [] };
}
// Add site as a destination for this client
const destination: PeerDestination = {
destinationIP: site.subnet.split("/")[0],
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
};
// Check if this destination is already in the array to avoid duplicates
const isDuplicate = mappings[clientSite.endpoint].destinations.some(
(dest) =>
dest.destinationIP === destination.destinationIP &&
dest.destinationPort === destination.destinationPort
);
if (!isDuplicate) {
mappings[clientSite.endpoint].destinations.push(destination);
}
const key = `${dest.destinationIP}:${dest.destinationPort}`;
if (!destSet.has(key)) {
destSet.add(key);
mappings[endpoint].destinations.push(dest);
}
};
// Also handle site-to-site communication (all sites in the same org)
if (site.orgId) {
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, site.orgId));
// Process each site using the pre-fetched data.
for (const site of validSites) {
const siteDestination: PeerDestination = {
destinationIP: site.subnet!.split("/")[0],
destinationPort: site.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
};
for (const peer of orgSites) {
// Skip self
if (
peer.siteId === site.siteId ||
!peer.endpoint ||
!peer.subnet ||
!peer.listenPort
) {
// Add this site as a destination for each associated client.
const clientSites = clientSitesBySite.get(site.siteId);
if (clientSites) {
for (const clientSite of clientSites) {
if (!clientSite.endpoint) {
continue;
}
addDestination(clientSite.endpoint, siteDestination);
}
}
// Add peer site as a destination for this site
if (!mappings[site.endpoint]) {
mappings[site.endpoint] = { destinations: [] };
}
const destination: PeerDestination = {
destinationIP: peer.subnet.split("/")[0],
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
};
// Check for duplicates
const isDuplicate = mappings[site.endpoint].destinations.some(
(dest) =>
dest.destinationIP === destination.destinationIP &&
dest.destinationPort === destination.destinationPort
);
if (!isDuplicate) {
mappings[site.endpoint].destinations.push(destination);
// Site-to-site communication (all sites in the same org).
if (site.orgId != null) {
const peers = sitesByOrg.get(site.orgId);
if (peers) {
for (const peer of peers) {
if (peer.siteId === site.siteId) {
continue;
}
addDestination(site.endpoint!, {
destinationIP: peer.subnet!.split("/")[0],
destinationPort: peer.listenPort! || 1 // this satisfies gerbil for now but should be reevaluated
});
}
}
}

View File

@@ -11,7 +11,7 @@ import {
ExitNode
} from "@server/db";
import { db } from "@server/db";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
@@ -185,16 +185,20 @@ export async function updateAndGenerateEndpointDestinations(
const sitesOnExitNode = await db
.select({
siteId: sites.siteId,
newtId: newts.newtId,
subnet: sites.subnet,
listenPort: sites.listenPort,
publicKey: sites.publicKey,
endpoint: clientSitesAssociationsCache.endpoint
endpoint: clientSitesAssociationsCache.endpoint,
isRelayed: clientSitesAssociationsCache.isRelayed,
isJitMode: clientSitesAssociationsCache.isJitMode
})
.from(sites)
.innerJoin(
clientSitesAssociationsCache,
eq(sites.siteId, clientSitesAssociationsCache.siteId)
)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.exitNodeId, exitNode.exitNodeId),
@@ -202,24 +206,36 @@ export async function updateAndGenerateEndpointDestinations(
)
);
// Update clientSites for each site on this exit node
// Format the endpoint properly for both IPv4 and IPv6
const formattedEndpoint = formatEndpoint(ip, port);
// Determine which rows actually need updating and whether the endpoint
// (as opposed to only the publicKey) changed for any of them.
const siteIdsToUpdate: number[] = [];
const sitesWithNewtsToUpdate: { siteId: number; newtId: string }[] = [];
let endpointChanged = false;
for (const site of sitesOnExitNode) {
// logger.debug(
// `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}`
// );
// Format the endpoint properly for both IPv4 and IPv6
const formattedEndpoint = formatEndpoint(ip, port);
// if the public key or endpoint has changed, update it otherwise continue
if (
site.endpoint === formattedEndpoint &&
site.publicKey === publicKey
) {
continue;
}
siteIdsToUpdate.push(site.siteId);
if (!site.isRelayed && !site.isJitMode) {
sitesWithNewtsToUpdate.push({
siteId: site.siteId,
newtId: site.newtId
});
}
if (site.endpoint !== formattedEndpoint) {
endpointChanged = true;
}
}
const [updatedClientSitesAssociationsCache] = await db
if (siteIdsToUpdate.length > 0) {
// Single bulk update for all affected rows for this client on this exit node
await db
.update(clientSitesAssociationsCache)
.set({
endpoint: formattedEndpoint,
@@ -228,24 +244,30 @@ export async function updateAndGenerateEndpointDestinations(
.where(
and(
eq(clientSitesAssociationsCache.clientId, olm.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
inArray(
clientSitesAssociationsCache.siteId,
siteIdsToUpdate
)
)
)
.returning();
);
if (
updatedClientSitesAssociationsCache.endpoint !==
site.endpoint && // this is the endpoint from the join table not the site
updatedClient.pubKey === publicKey // only trigger if the client's public key matches the current public key which means it has registered so we dont prematurely send the update
) {
// Only trigger downstream peer updates once per hole punch: the
// endpoint is the same for every site on this exit node, and
// handleClientEndpointChange already fans out to all connected
// sites for this client.
if (endpointChanged && updatedClient.pubKey === publicKey) {
logger.info(
`ClientSitesAssociationsCache for client ${olm.clientId} and site ${site.siteId} endpoint changed from ${site.endpoint} to ${updatedClientSitesAssociationsCache.endpoint}`
`ClientSitesAssociationsCache for client ${olm.clientId} endpoint changed to ${formattedEndpoint} for ${siteIdsToUpdate.length} site(s) on exit node ${exitNode.exitNodeId}`
);
// Handle any additional logic for endpoint change
handleClientEndpointChange(
sitesWithNewtsToUpdate,
olm.clientId,
updatedClientSitesAssociationsCache.endpoint!
);
formattedEndpoint
).catch((error) => {
logger.error(
`Failed to handle client endpoint change for client ${olm.clientId}: ${error}`
);
});
}
}
@@ -336,59 +358,14 @@ export async function updateAndGenerateEndpointDestinations(
`Site ${newt.siteId} endpoint changed from ${site.endpoint} to ${updatedSite.endpoint}`
);
// Handle any additional logic for endpoint change
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!).catch(
(error) => {
logger.error(
`Failed to handle site endpoint change for site ${newt.siteId}: ${error}`
);
}
);
}
// if (!updatedSite || !updatedSite.subnet) {
// logger.warn(`Site not found: ${newt.siteId}`);
// throw new Error("Site not found");
// }
// Find all clients that connect to this site
// const sitesClientPairs = await db
// .select()
// .from(clientSites)
// .where(eq(clientSites.siteId, newt.siteId));
// THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING
// Get client details for each client
// for (const pair of sitesClientPairs) {
// const [client] = await db
// .select()
// .from(clients)
// .where(eq(clients.clientId, pair.clientId));
// if (client && client.endpoint) {
// const [host, portStr] = client.endpoint.split(':');
// if (host && portStr) {
// destinations.push({
// destinationIP: host,
// destinationPort: parseInt(portStr, 10)
// });
// }
// }
// }
// If this is a newt/site, also add other sites in the same org
// if (updatedSite.orgId) {
// const orgSites = await db
// .select()
// .from(sites)
// .where(eq(sites.orgId, updatedSite.orgId));
// for (const site of orgSites) {
// // Don't add the current site to the destinations
// if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) {
// const [host, portStr] = site.endpoint.split(':');
// if (host && portStr) {
// destinations.push({
// destinationIP: host,
// destinationPort: site.listenPort
// });
// }
// }
// }
// }
}
return destinations;
}
@@ -408,12 +385,14 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
return;
}
// Get all non-relayed clients connected to this site
// Get all non-relayed and not jit clients connected to this site
const connectedClients = await db
.select({
online: clients.online,
clientId: clients.clientId,
olmId: olms.olmId,
isRelayed: clientSitesAssociationsCache.isRelayed
isRelayed: clientSitesAssociationsCache.isRelayed,
isJitMode: clientSitesAssociationsCache.isJitMode
})
.from(clientSitesAssociationsCache)
.innerJoin(
@@ -423,32 +402,36 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
.innerJoin(olms, eq(olms.clientId, clients.clientId))
.where(
and(
eq(clients.online, true), // the client has to be online or it does not matter...
eq(clientSitesAssociationsCache.siteId, siteId),
eq(clientSitesAssociationsCache.isRelayed, false)
eq(clientSitesAssociationsCache.isRelayed, false),
eq(clientSitesAssociationsCache.isJitMode, false)
)
);
// Update each non-relayed client with the new site endpoint
for (const client of connectedClients) {
try {
await updateOlmPeer(
client.clientId,
{
siteId: siteId,
publicKey: site.publicKey,
endpoint: newEndpoint
},
client.olmId
);
logger.debug(
`Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update client ${client.clientId} with new site endpoint: ${error}`
);
}
}
// Update each non-relayed client with the new site endpoint (in parallel)
await Promise.allSettled(
connectedClients.map(async (client) => {
try {
await updateOlmPeer(
client.clientId,
{
siteId: siteId,
publicKey: site.publicKey!,
endpoint: newEndpoint
},
client.olmId
);
logger.debug(
`Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update client ${client.clientId} with new site endpoint: ${error}`
);
}
})
);
} catch (error) {
logger.error(
`Error handling site endpoint change for site ${siteId}: ${error}`
@@ -457,10 +440,11 @@ async function handleSiteEndpointChange(siteId: number, newEndpoint: string) {
}
async function handleClientEndpointChange(
sitesWithNewtsToUpdate: { siteId: number; newtId: string }[],
clientId: number,
newEndpoint: string
) {
// Alert all sites connected to this client that the endpoint has changed (only if NOT relayed)
// Alert all sites connected to this client that the endpoint has changed (only if NOT relayed and NOT JIT MODE)
try {
// Get client details
const [client] = await db
@@ -474,58 +458,42 @@ async function handleClientEndpointChange(
return;
}
// Get all non-relayed sites connected to this client
const connectedSites = await db
.select({
siteId: sites.siteId,
newtId: newts.newtId,
isRelayed: clientSitesAssociationsCache.isRelayed,
subnet: clients.subnet
})
.from(clientSitesAssociationsCache)
.innerJoin(
sites,
eq(clientSitesAssociationsCache.siteId, sites.siteId)
)
.innerJoin(newts, eq(newts.siteId, sites.siteId))
.innerJoin(
clients,
eq(clientSitesAssociationsCache.clientId, clients.clientId)
)
.where(
and(
eq(clientSitesAssociationsCache.clientId, clientId),
eq(clientSitesAssociationsCache.isRelayed, false)
)
if (sitesWithNewtsToUpdate.length > 250) {
logger.warn(
`Client ${clientId} has ${sitesWithNewtsToUpdate.length} connected sites so the client will be in jit mode anyway, skipping endpoint updates`
);
return;
}
// Update each non-relayed site with the new client endpoint
for (const siteData of connectedSites) {
try {
if (!siteData.subnet) {
// Update each non-relayed site with the new client endpoint (in parallel)
await Promise.allSettled(
sitesWithNewtsToUpdate.map(async ({ siteId, newtId }) => {
if (!client.pubKey) {
logger.warn(
`Client ${clientId} has no subnet, skipping update for site ${siteData.siteId}`
`Client ${clientId} has no public key, skipping update for site ${siteId}`
);
continue;
return;
}
await updateNewtPeer(
siteData.siteId,
client.pubKey,
{
endpoint: newEndpoint
},
siteData.newtId
);
logger.debug(
`Updated site ${siteData.siteId} with new client ${clientId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update site ${siteData.siteId} with new client endpoint: ${error}`
);
}
}
try {
await updateNewtPeer(
siteId,
client.pubKey,
{
endpoint: newEndpoint
},
newtId
);
logger.debug(
`Updated site ${siteId} with new client ${clientId} endpoint: ${newEndpoint}`
);
} catch (error) {
logger.error(
`Failed to update site ${siteId} with new client endpoint: ${error}`
);
}
})
);
} catch (error) {
logger.error(
`Error handling client endpoint change for client ${clientId}: ${error}`

View File

@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
// Gerbil routes
const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter);

View File

@@ -0,0 +1,10 @@
import type { Label } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListOrgLabelsResponse = PaginatedResponse<{
labels: Omit<Label, "orgId">[];
}>;
export type CreateOrEditLabelResponse = {
label: Label;
};

View File

@@ -1,4 +1,6 @@
import {
browserGatewayTarget,
BrowserGatewayTarget,
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
@@ -16,6 +18,7 @@ import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
formatEndpoint,
generateSubnetProxyTargetV2,
@@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
const allBrowserGatewayTargets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
// Filter out invalid targets
@@ -304,9 +312,22 @@ export async function buildTargetConfigurationForNewtClient(
(target) => target !== null
);
const serverSecret = config.getRawConfig().server.secret!;
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
const decryptAuthToken = decrypt(t.authToken, serverSecret);
return {
id: t.browserGatewayTargetId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
authToken: decryptAuthToken
};
});
return {
validHealthCheckTargets,
tcpTargets,
udpTargets
udpTargets,
browserGatewayTargets
};
}

View File

@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const siteId = newt.siteId;
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
message.data;
const {
publicKey,
pingResults,
newtVersion,
backwardsCompatible,
chainId
} = message.data;
if (!publicKey) {
logger.warn("Public key not provided");
return;
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId));
}
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
tcp: tcpTargets
},
healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets,
chainId: chainId
}
},

View File

@@ -9,8 +9,12 @@ import {
import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(site.siteId);
const {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
@@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
},
healthCheckTargets: validHealthCheckTargets,
peers: peers,
clientTargets: targets
clientTargets: targets,
browserGatewayTargets: browserGatewayTargets
}
},
{

View File

@@ -1,7 +1,9 @@
import { Target, TargetHealthCheck } from "@server/db";
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export async function addTargets(
newtId: string,
@@ -239,3 +241,55 @@ export async function removeTargets(
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function sendBrowserGatewayTargets(
newtId: string,
targets: BrowserGatewayTarget[],
version?: string | null
) {
if (targets.length === 0) return;
const payload = targets.map((t) => {
const decryptAuthToken = decrypt(
t.authToken,
config.getRawConfig().server.secret!
);
return {
id: t.browserGatewayTargetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
authToken: decryptAuthToken
};
});
await sendToClient(
newtId,
{
type: "newt/browsergateway/add",
data: {
targets: payload
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeBrowserGatewayTarget(
newtId: string,
browserGatewayTargetId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: "newt/browsergateway/remove",
data: {
ids: [browserGatewayTargetId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}

View File

@@ -5,6 +5,7 @@ import {
db,
exitNodes,
networks,
SiteResource,
siteNetworks,
siteResources,
sites
@@ -15,7 +16,7 @@ import {
generateRemoteSubnets
} from "@server/lib/ip";
import logger from "@server/logger";
import { and, eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config";
@@ -27,11 +28,11 @@ export async function buildSiteConfigurationForOlmClient(
) {
const siteConfigurations: {
siteId: number;
name?: string
endpoint?: string
publicKey?: string
serverIP?: string | null
serverPort?: number | null
name?: string;
endpoint?: string;
publicKey?: string;
serverIP?: string | null;
serverPort?: number | null;
remoteSubnets?: string[];
aliases: Alias[];
}[] = [];
@@ -46,50 +47,79 @@ export async function buildSiteConfigurationForOlmClient(
)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
if (sitesData.length === 0) {
return siteConfigurations;
}
// Batch-fetch every site resource this client has access to across ALL sites
// in a single query, then group by siteId in memory. This avoids issuing one
// query per site (which would be N round-trips for N sites).
const allClientSiteResources = await db
.select({
siteResource: siteResources,
siteId: siteNetworks.siteId
})
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
for (const row of allClientSiteResources) {
const arr = siteResourcesBySiteId.get(row.siteId);
if (arr) {
arr.push(row.siteResource);
} else {
siteResourcesBySiteId.set(row.siteId, [row.siteResource]);
}
}
// Batch-fetch exit nodes for all sites in one query (only needed in relay mode).
const exitNodesById = new Map<number, typeof exitNodes.$inferSelect>();
if (!jitMode && relay) {
const exitNodeIds = Array.from(
new Set(
sitesData
.map(({ sites: s }) => s.exitNodeId)
.filter((id): id is number => id != null)
)
);
if (exitNodeIds.length > 0) {
const nodes = await db
.select()
.from(exitNodes)
.where(inArray(exitNodes.exitNodeId, exitNodeIds));
for (const n of nodes) {
exitNodesById.set(n.exitNodeId, n);
}
}
}
const clientsStartPort = config.getRawConfig().gerbil.clients_start_port;
const peerOps: Promise<unknown>[] = [];
// Process each site
for (const {
sites: site,
clientSitesAssociationsCache: association
} of sitesData) {
const allSiteResources = await db // only get the site resources that this client has access to
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
eq(networks.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
)
);
const allSiteResources = siteResourcesBySiteId.get(site.siteId) ?? [];
if (jitMode) {
// Add site configuration to the array
siteConfigurations.push({
siteId: site.siteId,
// remoteSubnets: generateRemoteSubnets(
// allSiteResources.map(({ siteResources }) => siteResources)
// ),
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
)
// remoteSubnets: generateRemoteSubnets(allSiteResources),
aliases: generateAliasConfig(allSiteResources)
});
continue;
}
@@ -109,10 +139,9 @@ export async function buildSiteConfigurationForOlmClient(
continue;
}
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
logger.warn(
`Site ${site.siteId} has no public key, skipping`
);
if (!site.publicKey || site.publicKey == "") {
// the site is not ready to accept new peers
logger.warn(`Site ${site.siteId} has no public key, skipping`);
continue;
}
@@ -128,7 +157,7 @@ export async function buildSiteConfigurationForOlmClient(
logger.info(
`Public key mismatch. Deleting old peer from site ${site.siteId}...`
);
await deletePeer(site.siteId, client.pubKey!);
peerOps.push(deletePeer(site.siteId, client.pubKey!));
}
if (!site.subnet) {
@@ -136,27 +165,19 @@ export async function buildSiteConfigurationForOlmClient(
continue;
}
const [clientSite] = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
)
.limit(1);
// Add the peer to the exit node for this site
if (clientSite.endpoint && publicKey) {
// Add the peer to the exit node for this site. The endpoint comes from
// the already-joined association row above, so no extra query needed.
if (association.endpoint && publicKey) {
logger.info(
`Adding peer ${publicKey} to site ${site.siteId} with endpoint ${clientSite.endpoint}`
`Adding peer ${publicKey} to site ${site.siteId} with endpoint ${association.endpoint}`
);
peerOps.push(
addPeer(site.siteId, {
publicKey: publicKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: relay ? "" : association.endpoint
})
);
await addPeer(site.siteId, {
publicKey: publicKey,
allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client
endpoint: relay ? "" : clientSite.endpoint
});
} else {
logger.warn(
`Client ${client.clientId} has no endpoint, skipping peer addition`
@@ -165,16 +186,12 @@ export async function buildSiteConfigurationForOlmClient(
let relayEndpoint: string | undefined = undefined;
if (relay) {
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId))
.limit(1);
const exitNode = exitNodesById.get(site.exitNodeId);
if (!exitNode) {
logger.warn(`Exit node not found for site ${site.siteId}`);
continue;
}
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
relayEndpoint = `${exitNode.endpoint}:${clientsStartPort}`;
}
// Add site configuration to the array
@@ -186,12 +203,16 @@ export async function buildSiteConfigurationForOlmClient(
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort,
remoteSubnets: generateRemoteSubnets(
allSiteResources.map(({ siteResources }) => siteResources)
),
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
)
remoteSubnets: generateRemoteSubnets(allSiteResources),
aliases: generateAliasConfig(allSiteResources)
});
}
// Run all peer add/delete operations concurrently rather than serially per
// site, so total time is bounded by the slowest call instead of the sum.
if (peerOps.length > 0) {
Promise.allSettled(peerOps).catch((err) => {
logger.error("Error processing peer operations: ", err);
});
}

View File

@@ -8,7 +8,7 @@ import {
ExitNode,
exitNodes,
sites,
clientSitesAssociationsCache,
clientSitesAssociationsCache
} from "@server/db";
import { olms } from "@server/db";
import HttpCode from "@server/types/HttpCode";
@@ -28,6 +28,7 @@ import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
import { APP_VERSION } from "@server/lib/consts";
import { build } from "@server/build";
export const olmGetTokenBodySchema = z.object({
olmId: z.string(),
@@ -220,6 +221,22 @@ export async function getOlmToken(
)
.where(eq(clientSitesAssociationsCache.clientId, clientIdToUse!));
if (clientSites.length > 250 && build == "saas") {
// set all of the cache rows isJitMode to true
await db
.update(clientSitesAssociationsCache)
.set({ isJitMode: true })
.where(
and(
eq(
clientSitesAssociationsCache.clientId,
clientIdToUse!
),
eq(clientSitesAssociationsCache.isJitMode, false)
)
);
}
// Extract unique exit node IDs
const exitNodeIds = Array.from(
new Set(

View File

@@ -1,4 +1,4 @@
import { db, orgs } from "@server/db";
import { db, orgs, primaryDb } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import {
clients,
@@ -7,7 +7,7 @@ import {
olms,
sites
} from "@server/db";
import { count, eq } from "drizzle-orm";
import { and, count, eq, ne, or } from "drizzle-orm";
import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app";
@@ -81,7 +81,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.where(eq(olms.olmId, olm.olmId));
}
const [client] = await db
const [client] = await primaryDb // read from the primary here so there is no latency with the last update on the holepunch
.select()
.from(clients)
.where(eq(clients.clientId, olm.clientId))
@@ -98,7 +98,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.blocked) {
logger.debug(
`[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
return;
@@ -107,7 +107,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.approvalState == "pending") {
logger.debug(
`[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
return;
@@ -136,7 +136,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!org) {
logger.warn("[handleOlmRegisterMessage] Org not found", {
orgId: client.orgId
orgId: client.orgId,
clientId: client.clientId
});
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
return;
@@ -145,7 +146,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (orgId) {
if (!olm.userId) {
logger.warn("[handleOlmRegisterMessage] Olm has no user ID", {
orgId: client.orgId
orgId: client.orgId,
clientId: client.clientId
});
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
return;
@@ -156,7 +158,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!userSession || !user) {
logger.warn(
"[handleOlmRegisterMessage] Invalid user session for olm register",
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
return;
@@ -164,7 +166,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (user.userId !== olm.userId) {
logger.warn(
"[handleOlmRegisterMessage] User ID mismatch for olm register",
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
return;
@@ -182,13 +184,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.debug("[handleOlmRegisterMessage] Policy check result", {
orgId: client.orgId,
clientId: client.clientId,
policyCheck
});
if (policyCheck?.error) {
logger.error(
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return;
@@ -197,7 +200,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
@@ -209,7 +212,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
) {
logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
@@ -219,7 +222,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} else if (policyCheck.policies?.requiredTwoFactor === false) {
logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
@@ -229,7 +232,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} else if (!policyCheck.allowed) {
logger.warn(
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return;
@@ -253,7 +256,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// Prepare an array to store site configurations
logger.debug(
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
let jitMode = false;
@@ -263,19 +266,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
logger.info(
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
jitMode = true;
}
logger.debug(
`[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
if (!publicKey) {
logger.warn("[handleOlmRegisterMessage] Public key not provided", {
orgId: client.orgId
orgId: client.orgId,
clientId: client.clientId
});
return;
}
@@ -283,7 +287,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.pubKey !== publicKey || client.archived) {
logger.info(
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
// Update the client's public key
await db
@@ -301,7 +305,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
isRelayed: relay == true,
isJitMode: jitMode
})
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
or(
ne(
clientSitesAssociationsCache.isRelayed,
relay == true
),
ne(clientSitesAssociationsCache.isJitMode, jitMode)
)
)
);
}
// this prevents us from accepting a register from an olm that has not hole punched yet.
@@ -310,7 +325,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
logger.warn(
`[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
{ orgId: client.orgId }
{ orgId: client.orgId, clientId: client.clientId }
);
return;
}

View File

@@ -17,7 +17,7 @@ import { initPeerAddHandshake } from "./peers";
export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
context
) => {
logger.info("Handling register olm message!");
logger.info("Handle Olm Server Init Add Peer Handshake Message");
const { message, client: c, sendToClient } = context;
const olm = c as Olm;

View File

@@ -9,16 +9,50 @@ import {
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm";
import { count, eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import { canCompress } from "@server/lib/clientVersionChecks";
import { build } from "@server/build";
export async function sendOlmSyncMessage(olm: Olm, client: Client) {
// Get all sites data
const sitesCountResult = await db
.select({ count: count() })
.from(sites)
.innerJoin(
clientSitesAssociationsCache,
eq(sites.siteId, clientSitesAssociationsCache.siteId)
)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
// Extract the count value from the result array
const sitesCount =
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
// Prepare an array to store site configurations
logger.debug(
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
{ orgId: client.orgId }
);
let jitMode = false;
if (sitesCount > 250 && build == "saas") {
// THIS IS THE MAX ON THE BUSINESS TIER
// we have too many sites
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
logger.info(
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
{ orgId: client.orgId }
);
jitMode = true;
}
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
const siteConfigurations = await buildSiteConfigurationForOlmClient(
client,
client.pubKey,
false
false,
jitMode
);
// Get all exit nodes from sites where the client has peers
@@ -82,7 +116,6 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
exitNodes: exitNodesData
}
},
{
compress: canCompress(olm.version, "olm")
}

View File

@@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export type GetBrowserTargetResponse = {
ip: string;
port: number;
authToken: string;
orgId: string;
resourceId: number;
niceId: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptedAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { and, eq, or, inArray } from "drizzle-orm";
import { db, DB_TYPE } from "@server/db";
import { and, eq, or, inArray, sql } from "drizzle-orm";
import {
resources,
userResources,
@@ -12,7 +12,9 @@ import {
resourceWhitelist,
siteResources,
userSiteResources,
roleSiteResources
roleSiteResources,
siteNetworks,
sites
} from "@server/db";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -156,9 +158,24 @@ export async function getUserResources(
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean | null;
siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
siteOnlines: boolean[];
}> = [];
if (accessibleSiteResourceIds.length > 0) {
siteResourcesData = await db
const aggCol = <T>(column: any) => {
if (DB_TYPE === "sqlite") {
return sql<T>`json_group_array(${column})`;
}
return sql<T>`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`;
};
const siteResourcesRaw = await db
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
@@ -170,9 +187,22 @@ export async function getUserResources(
fullDomain: siteResources.fullDomain,
enabled: siteResources.enabled,
alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress
aliasAddress: siteResources.aliasAddress,
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
siteIds: aggCol<number[]>(sites.siteId),
siteNames: aggCol<string[]>(sites.name),
siteNiceIds: aggCol<string[]>(sites.niceId),
siteAddresses: aggCol<(string | null)[]>(sites.address),
siteOnlines: aggCol<boolean[]>(sites.online)
})
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(
@@ -182,7 +212,55 @@ export async function getUserResources(
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
)
);
)
.groupBy(siteResources.siteResourceId);
siteResourcesData = siteResourcesRaw.map((row: any) => {
if (DB_TYPE !== "sqlite") {
return row;
}
const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[];
const siteNamesRaw = JSON.parse(row.siteNames) as (
| string
| null
)[];
const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (
| string
| null
)[];
const siteAddressesRaw = JSON.parse(row.siteAddresses) as (
| string
| null
)[];
const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (
| 0
| 1
| null
)[];
const siteIds: number[] = [];
const siteNames: string[] = [];
const siteNiceIds: string[] = [];
const siteAddresses: (string | null)[] = [];
const siteOnlines: boolean[] = [];
for (let i = 0; i < siteIdsRaw.length; i++) {
if (siteIdsRaw[i] == null) continue;
siteIds.push(siteIdsRaw[i] as number);
siteNames.push((siteNamesRaw[i] ?? "") as string);
siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string);
siteAddresses.push(siteAddressesRaw[i] ?? null);
siteOnlines.push(siteOnlinesRaw[i] === 1);
}
return {
...row,
siteIds,
siteNames,
siteNiceIds,
siteAddresses,
siteOnlines
};
});
}
// Check for password, pincode, and whitelist protection for each resource
@@ -260,6 +338,14 @@ export async function getUserResources(
enabled: siteResource.enabled,
alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress,
tcpPortRangeString: siteResource.tcpPortRangeString,
udpPortRangeString: siteResource.udpPortRangeString,
disableIcmp: siteResource.disableIcmp,
siteIds: siteResource.siteIds,
siteNames: siteResource.siteNames,
siteNiceIds: siteResource.siteNiceIds,
siteAddresses: siteResource.siteAddresses,
siteOnlines: siteResource.siteOnlines,
type: "site" as const
};
});
@@ -302,11 +388,19 @@ export type GetUserResourcesResponse = {
destination: string;
mode: string;
protocol: string | null;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
siteOnlines: boolean[];
type: "site";
}>;
};

View File

@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getBrowserTarget";

View File

@@ -1,7 +1,10 @@
import {
browserGatewayTarget,
db,
labels,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourceLabels,
resourcePassword,
resourcePincode,
resources,
@@ -9,8 +12,11 @@ import {
sites,
targetHealthCheck,
targets,
userResources
userResources,
type Label
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -140,6 +146,7 @@ export type ResourceWithTargets = {
headerAuthId: number | null;
wildcard: boolean;
health: string | null;
browserAccessType: string | null;
targets: Array<{
targetId: number;
ip: string;
@@ -154,6 +161,7 @@ export type ResourceWithTargets = {
siteNiceId: string;
online?: boolean; // undefined for local sites
}>;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
};
function queryResourcesBase() {
@@ -177,7 +185,8 @@ function queryResourcesBase() {
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health
health: resources.health,
browserAccessType: resources.browserAccessType
})
.from(resources)
.leftJoin(
@@ -288,6 +297,11 @@ export async function listResources(
);
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>;
if (req.user) {
accessibleResources = await db
@@ -325,24 +339,6 @@ export async function listResources(
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${resources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.fullDomain})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof enabled !== "undefined") {
conditions.push(eq(resources.enabled, enabled));
}
@@ -386,6 +382,32 @@ export async function listResources(
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite));
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = queryResourcesBase().where(and(...conditions));
@@ -407,6 +429,36 @@ export async function listResources(
]);
const resourceIdList = rows.map((row) => row.resourceId);
let labelsForResources: Array<{
labelId: number;
name: string;
color: string;
resourceId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForResources =
resourceIdList.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
resourceId: resourceLabels.resourceId
})
.from(labels)
.innerJoin(
resourceLabels,
eq(resourceLabels.labelId, labels.labelId)
)
.where(
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId));
}
const allResourceTargets =
resourceIdList.length === 0
? []
@@ -433,6 +485,30 @@ export async function listResources(
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -453,12 +529,16 @@ export async function listResources(
protocol: row.protocol,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
browserAccessType: row.browserAccessType,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
health: row.health ?? null,
targets: [],
sites: []
sites: [],
labels: labelsForResources.filter(
(l) => l.resourceId === row.resourceId
)
};
map.set(row.resourceId, entry);
}
@@ -493,6 +573,21 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}

View File

@@ -24,7 +24,10 @@ import {
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -68,7 +71,12 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional()
postAuthPath: z.string().nullable().optional(),
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional(),
// SSH settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"

View File

@@ -9,7 +9,10 @@ import {
siteResources,
targets,
sites,
userSites
userSites,
labels,
siteLabels,
type Label
} from "@server/db";
import cache from "#dynamic/lib/cache";
import response from "@server/lib/response";
@@ -23,6 +26,8 @@ import createHttpError from "http-errors";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
// Stale-while-revalidate: keeps the last successfully fetched version so that
// a transient network failure / timeout does not flip every site back to
@@ -187,7 +192,7 @@ const listSitesSchema = z.object({
function querySitesBase() {
return db
.select({
.selectDistinct({
siteId: sites.siteId,
niceId: sites.niceId,
name: sites.name,
@@ -233,6 +238,7 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
online?: SiteRowBase["online"]; // undefined for local sites
newtUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
};
export type ListSitesResponse = PaginatedResponse<{
@@ -308,6 +314,11 @@ export async function listSites(
.where(eq(sites.orgId, orgId));
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const { pageSize, page, query, sort_by, order, online, status } =
parsedQuery.data;
@@ -319,33 +330,43 @@ export async function listSites(
eq(sites.orgId, orgId)
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online));
}
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${sites.name})`, q),
like(sql`LOWER(${sites.niceId})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
querySitesBase().where(and(...conditions)).as("filtered_sites")
);
const siteListQuery = baseQuery
@@ -367,11 +388,46 @@ export async function listSites(
// Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion();
const siteIds = rows.map((site) => site.siteId);
let labelsForSites: Array<{
labelId: number;
name: string;
color: string;
siteId: number;
}> = [];
if (isLabelFeatureEnabled) {
labelsForSites =
siteIds.length === 0
? []
: await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteId: siteLabels.siteId
})
.from(labels)
.innerJoin(
siteLabels,
eq(siteLabels.labelId, labels.labelId)
)
.where(inArray(siteLabels.siteId, siteIds))
.orderBy(asc(siteLabels.siteLabelId));
}
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
// Initially set to false, will be updated if version check succeeds
siteWithUpdate.newtUpdateAvailable = false;
return siteWithUpdate;
// associate labels
const labelsForSite = labelsForSites.filter(
(label) => label.siteId === site.siteId
);
return { ...siteWithUpdate, labels: labelsForSite };
});
// Try to get the latest version, but don't block if it fails

View File

@@ -1,4 +1,14 @@
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import {
db,
DB_TYPE,
Label,
SiteResource,
siteNetworks,
siteResourceLabels,
siteResources,
sites,
labels
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -9,6 +19,8 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
orgId: z.string()
@@ -69,16 +81,11 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
default: "asc",
description: "Sort order"
}),
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
siteId: z.coerce.number<string>().int().positive().optional().openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
});
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
@@ -88,6 +95,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
})[];
}>;
@@ -234,6 +242,11 @@ export async function listAllSiteResourcesByOrg(
const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
@@ -258,41 +271,41 @@ export async function listAllSiteResourcesByOrg(
inArray(siteResources.siteResourceId, resourcesForSite)
);
}
if (query) {
conditions.push(
or(
like(
sql`LOWER(${siteResources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.destination})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.alias})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${siteResources.aliasAddress})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (mode) {
conditions.push(eq(siteResources.mode, mode));
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
like(sql`LOWER(${siteResources.name})`, q),
like(sql`LOWER(${siteResources.niceId})`, q),
like(sql`LOWER(${siteResources.destination})`, q),
like(sql`LOWER(${siteResources.alias})`, q),
like(sql`LOWER(${siteResources.aliasAddress})`, q),
like(sql`LOWER(${sites.name})`, q)
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList));
}
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
@@ -315,11 +328,51 @@ export async function listAllSiteResourcesByOrg(
countQuery
]);
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
const siteResourcesList = siteResourcesRaw.map(
transformSiteResourceRow
);
const siteResourceIdList = siteResourcesList.map(
(r) => r.siteResourceId
);
let labelsForSiteResources: Array<{
labelId: number;
name: string;
color: string;
siteResourceId: number;
}> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
labelsForSiteResources = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
}
return response<ListAllSiteResourcesByOrgResponse>(res, {
data: {
siteResources: siteResourcesList,
siteResources: siteResourcesList.map((r) => ({
...r,
labels: labelsForSiteResources.filter(
(l) => l.siteResourceId === r.siteResourceId
)
})),
pagination: {
total: totalCount,
pageSize,
@@ -340,4 +393,4 @@ export async function listAllSiteResourcesByOrg(
)
);
}
}
}

View File

@@ -1352,6 +1352,12 @@ export default function BillingPage() {
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
<p className="text-sm text-muted-foreground mt-2">
{t(
"billingManageLicenseSubscriptionDescription"
) ||
"Manage your subscription for paid self-hosted license keys and download invoices."}
</p>
</div>
</div>
</SettingsSectionBody>

View File

@@ -0,0 +1,63 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Labels"
};
type Props = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function LabelsPage({ params, searchParams }: Props) {
const { orgId } = await params;
const searchParamsObj = new URLSearchParams(await searchParams);
let labels: ListOrgLabelsResponse["labels"] = [];
let pagination: ListOrgLabelsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
labels = responseData.labels;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("labels")}
description={t("orgLabelsDescription")}
/>
<OrgLabelsTable
labels={labels}
orgId={orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

@@ -76,7 +76,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
agent: client.agent,
archived: client.archived || false,
blocked: client.blocked || false,
approvalState: client.approvalState ?? "approved"
approvalState: client.approvalState ?? "approved",
labels: client.labels ?? []
};
};

View File

@@ -9,7 +9,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -294,7 +294,7 @@ export default function ConnectionLogsPage() {
} catch (error) {
toast({
title: t("error"),
description: t("Failed to filter logs"),
description: formatAxiosError(error),
variant: "destructive"
});
} finally {

View File

@@ -127,7 +127,8 @@ export default async function ClientResourcesPage(
authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null
fullDomain: siteResource.fullDomain ?? null,
labels: siteResource.labels ?? []
};
}
);

View File

@@ -507,7 +507,9 @@ export default function GeneralForm() {
name: data.name,
niceId: data.niceId,
subdomain: data.subdomain
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
? toASCII(
finalizeSubdomainSanitize(data.subdomain, true)
)
: undefined,
domainId: data.domainId,
proxyPort: data.proxyPort
@@ -555,13 +557,15 @@ export default function GeneralForm() {
return (
<>
<SettingsContainer>
{resource?.resourceId && resource?.orgId && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
{resource?.resourceId &&
resource?.orgId &&
resource.browserAccessType == "http" && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -580,43 +584,44 @@ export default function GeneralForm() {
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!resource.http && (
<>
@@ -726,28 +731,31 @@ export default function GeneralForm() {
control={form.control}
name="enabled"
render={() => (
<FormItem className="col-span-2">
<div className="flex items-center space-x-2">
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
</div>
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}

View File

@@ -121,6 +121,10 @@ export default function ReverseProxyTargetsPage(props: {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const [targetMode, setTargetMode] = useState<
"http" | "ssh" | "rdp" | "vnc"
>((resource.browserAccessType as "http" | "ssh" | "rdp" | "vnc") || "http");
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
@@ -137,9 +141,12 @@ export default function ReverseProxyTargetsPage(props: {
orgId={params.orgId}
initialTargets={remoteTargets}
resource={resource}
targetMode={targetMode}
setTargetMode={setTargetMode}
updateResource={updateResource}
/>
{resource.http && (
{resource.http && targetMode === "http" && (
<ProxyResourceHttpForm
resource={resource}
updateResource={updateResource}
@@ -159,11 +166,17 @@ export default function ReverseProxyTargetsPage(props: {
function ProxyResourceTargetsForm({
orgId,
initialTargets,
resource
resource,
targetMode,
setTargetMode,
updateResource
}: {
initialTargets: LocalTarget[];
orgId: string;
resource: GetResourceResponse;
targetMode: "http" | "ssh" | "rdp" | "vnc";
setTargetMode: (mode: "http" | "ssh" | "rdp" | "vnc") => void;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -201,6 +214,11 @@ function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) {
return; // Already initialized
@@ -270,6 +288,41 @@ function ProxyResourceTargetsForm({
})
);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setTargetMode(bgt.type as "ssh" | "rdp" | "vnc");
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -356,7 +409,7 @@ function ProxyResourceTargetsForm({
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -375,7 +428,6 @@ function ProxyResourceTargetsForm({
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -404,9 +456,15 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
@@ -432,9 +490,15 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
@@ -717,6 +781,55 @@ function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
async function saveTargets() {
if (targetMode !== "http") {
try {
if (!bgDestination || !bgDestinationPort) {
if (bgTargetId) {
await api.delete(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`
);
setBgTargetId(null);
}
} else if (bgTargetId) {
await api.post(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`,
{
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: bgSiteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: bgSiteId ?? sites[0]?.siteId,
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
);
setBgTargetId(res.data.data.browserGatewayTargetId);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
return;
}
// Validate that no targets have blank IPs or invalid ports
const targetsWithInvalidFields = targets.filter(
(target) =>
@@ -791,12 +904,14 @@ function ProxyResourceTargetsForm({
}
toast({
title: targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description: targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
setTargetsToRemove([]);
@@ -829,102 +944,187 @@ function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length > 0 ? (
<div className="flex items-center gap-3 mb-4">
<span className="text-sm font-medium">Target Type</span>
<Select
value={targetMode}
onValueChange={async (v) => {
const mode = v as
| "http"
| "ssh"
| "rdp"
| "vnc";
setTargetMode(mode);
try {
await api.post(
`/resource/${resource.resourceId}`,
{ browserAccessType: mode }
);
updateResource({ browserAccessType: mode });
} catch (err) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
<SelectItem value="vnc">VNC</SelectItem>
</SelectContent>
</Select>
</div>
{targetMode === "http" ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{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) => {
const isActionsColumn =
cell.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow
key={headerGroup.id}
>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header
.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{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
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
) => {
const isActionsColumn =
cell
.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
}
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("targetNoOne")}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={
setIsAdvancedMode
}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{t("advancedMode")}
</label>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button
onClick={addNewTarget}
variant="outline"
@@ -932,50 +1132,91 @@ function ProxyResourceTargetsForm({
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size >
1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Destination
</label>
<Input
placeholder="192.168.1.1"
value={bgDestination}
onChange={(e) =>
setBgDestination(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Port
</label>
<Input
type="number"
placeholder={
targetMode === "rdp"
? "3389"
: targetMode === "ssh"
? "22"
: "5900"
}
value={bgDestinationPort}
onChange={(e) =>
setBgDestinationPort(e.target.value)
}
/>
</div>
</div>
{sites.length > 1 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Site
</label>
<Select
value={bgSiteId ? String(bgSiteId) : ""}
onValueChange={(v) =>
setBgSiteId(Number(v))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a site" />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem
key={site.siteId}
value={String(site.siteId)}
>
{site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>
<form className="self-end mt-4" action={formAction}>

View File

@@ -86,8 +86,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
},
{
title: t("proxy"),
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
title: t(`${resource.browserAccessType}Settings`),
href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.browserAccessType}`
}
];

View File

@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
`/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("rdpServer")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rdpServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
);
}

View File

@@ -0,0 +1,524 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import {
SitesSelector,
type Selectedsite
} from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
isNativeInitially ? "native" : "standard"
);
const isNative = sshServerMode === "native";
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
(resource.pamMode as "passthrough" | "push") || "passthrough"
);
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
"site" | "remote"
>(
isNativeInitially
? "site"
: (resource.authDaemonMode as "site" | "remote") || "site"
);
const form = useForm({
resolver: zodResolver(sshFormSchema),
defaultValues: {
authDaemonPort: (resource as any).authDaemonPort
? String((resource as any).authDaemonPort)
: "22123"
}
});
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
if (isNativeInitially) {
setSelectedNativeSite({
siteId: first.siteId,
name: first.siteName ?? String(first.siteId),
type: "newt" as const
});
setNativeExistingTarget({
browserGatewayTargetId: first.browserGatewayTargetId,
siteId: first.siteId
});
} else {
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const effectiveMode = isNative ? "native" : standardDaemonLocation;
const portVal = form.getValues().authDaemonPort;
const effectivePort =
!isNative && standardDaemonLocation === "remote" && portVal
? Number(portVal)
: null;
try {
await api.post(`/resource/${resource.resourceId}`, {
pamMode,
authDaemonMode: effectiveMode,
authDaemonPort: effectivePort
});
updateResource({
...resource,
pamMode,
authDaemonMode: effectiveMode
});
if (isNative) {
if (selectedNativeSite) {
if (nativeExistingTarget) {
await api.post(
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
{
type: "ssh",
destination: "localhost",
destinationPort: 22,
siteId: selectedNativeSite.siteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
}
);
setNativeExistingTarget({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: selectedNativeSite.siteId
});
}
}
} else {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map(
(res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
})
);
setExistingTargets([...toUpdate, ...newTargets]);
}
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
];
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
];
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("sshServer")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshServerMode")}
</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={setPamMode}
cols={2}
/>
</div>
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={setStandardDaemonLocation}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<Form {...form}>
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
<div className="space-y-3">
<div>
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{t("sshServerDestination")}
</h2>
<p className="text-sm text-muted-foreground">
{t("sshServerDestinationDescription")}
</p>
</div>
{isNative ? (
<Popover
open={nativeSiteOpen}
onOpenChange={setNativeSiteOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedNativeSite}
onSelectSite={(site) => {
setSelectedNativeSite(site);
setNativeSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
) : standardDaemonLocation !== "site" ? (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={false}
selectedSite={selectedSite}
onSiteChange={setSelectedSite}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)}
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { z } from "zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("vncServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
);
}

View File

@@ -713,7 +713,7 @@ export default function Page() {
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -732,7 +732,6 @@ export default function Page() {
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -1427,10 +1426,12 @@ export default function Page() {
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size >
1 && (
new Set(targets.map((t) => t.siteId))
.size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
{t(
"proxyMultiSiteRoundRobinNodeHelp"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
@@ -1627,7 +1628,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
`/${orgId}/settings/resources/proxy/${niceId}`
)
}
>

View File

@@ -111,6 +111,7 @@ export default async function ProxyResourcesPage(
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
labels: resource.labels,
authState: !resource.http
? "none"
: resource.sso ||
@@ -125,6 +126,7 @@ export default async function ProxyResourcesPage(
fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl,
wildcard: resource.wildcard,
browserAccessType: resource.browserAccessType,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
ip: target.ip,

View File

@@ -60,6 +60,7 @@ export default async function SitesPage(props: SitesPageProps) {
return {
name: site.name,
id: site.siteId,
labels: site.labels,
nice: site.niceId.toString(),
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -23,6 +23,7 @@ import {
Server,
Settings,
SquareMousePointer,
TagIcon,
TicketCheck,
Unplug,
User,
@@ -99,7 +100,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
...(build == "saas"
...(build === "saas"
? [
{
title: "sidebarRemoteExitNodes",
@@ -237,10 +238,19 @@ export const orgNavSections = (
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
}
},
...(build !== "oss"
? [
{
title: "labels",
href: "/{orgId}/settings/labels",
icon: <TagIcon className="size-4 flex-none" />
}
]
: [])
]
},
...(build == "saas" && options?.isPrimaryOrg
...(build === "saas" && options?.isPrimaryOrg
? [
{
title: "sidebarBillingAndLicenses",

522
src/app/rdp/RdpClient.tsx Normal file
View File

@@ -0,0 +1,522 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
IronError,
FileTransferProvider
} from "@devolutions/iron-remote-desktop/dist";
import type {
RdpFileTransferProvider,
FileInfo
} from "@devolutions/iron-remote-desktop-rdp/dist";
import { GetBrowserTargetResponse } from "@server/routers/resource";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"iron-remote-desktop": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
scale?: string;
verbose?: string;
flexcenter?: string;
module?: unknown;
},
HTMLElement
>;
}
}
}
type FormState = {
username: string;
password: string;
domain: string;
kdcProxyUrl: string;
pcb: string;
enableClipboard: boolean;
};
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === "object" &&
error !== null &&
typeof (error as IronError).backtrace === "function" &&
typeof (error as IronError).kind === "function"
);
};
export default function RdpClient({
target,
error
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_rdp_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
const userInteractionRef = useRef<UserInteraction | null>(null);
const backendRef = useRef<unknown>(null);
// Holds the RdpFileTransferProvider constructor so we can create a fresh
// instance per session (avoids stale upload state across reconnects).
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
null
);
// Active session's provider instance; replaced on each connect.
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
const extensionsRef = useRef<{
displayControl: (enable: boolean) => unknown;
preConnectionBlob: (pcb: string) => unknown;
kdcProxyUrl: (url: string) => unknown;
} | null>(null);
// Load the iron-remote-desktop modules client-side and register the
// `<iron-remote-desktop>` custom element.
useEffect(() => {
let cancelled = false;
(async () => {
const [coreMod, rdpMod] = await Promise.all([
import("@devolutions/iron-remote-desktop/dist"),
import("@devolutions/iron-remote-desktop-rdp/dist")
]);
if (cancelled) return;
await rdpMod.init("INFO");
backendRef.current = rdpMod.Backend;
extensionsRef.current = {
displayControl: rdpMod.displayControl,
preConnectionBlob: rdpMod.preConnectionBlob,
kdcProxyUrl: rdpMod.kdcProxyUrl
};
// Store the class; a fresh instance is created per session.
fileTransferClassRef.current =
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
// Importing the package registers the custom element as a side
// effect. Touch the default export to avoid tree-shaking.
void coreMod;
setModuleReady(true);
})().catch((err) => {
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
description: `${err}`
});
});
return () => {
cancelled = true;
};
}, []);
// Attach the "ready" listener synchronously the moment the custom
// element mounts. The custom element dispatches `ready` from its own
// `onMount`, so a deferred useEffect can race and miss it.
const remoteElementRef = (el: HTMLElement | null) => {
if (!el) return;
const onReady = (e: Event) => {
const event = e as CustomEvent;
userInteractionRef.current = event.detail.irgUserInteraction;
};
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
return;
}
userInteraction.setEnableClipboard(form.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
fileTransferRef.current?.dispose();
const ProviderClass = fileTransferClassRef.current;
const fileTransfer = ProviderClass ? new ProviderClass() : null;
fileTransferRef.current = fileTransfer;
if (fileTransfer) {
// Auto-download files when the remote copies them to clipboard.
fileTransfer.on("files-available", (files: FileInfo[]) => {
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.isDirectory) continue;
const { completion } = fileTransfer.downloadFile(file, i);
completion
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
})
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
description: `${err}`
});
});
}
});
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
});
// Register with the web component so CLIPRDR extensions are
// wired up before connect() builds the session.
userInteraction.enableFileTransfer(
fileTransfer as unknown as FileTransferProvider
);
}
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
return;
}
const destination = `${target.ip}:${target.port}`;
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withAuthToken(target.authToken)
.withDesktopSize({
width: window.innerWidth,
height: window.innerHeight
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
const termInfo = await sessionInfo.run();
fileTransferRef.current?.dispose();
fileTransferRef.current = null;
setShowLogin(true);
} catch (err) {
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
}
}
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
const u = ui();
if (!u) return;
if (cursorOverrideActive) {
u.setCursorStyleOverride(null);
} else {
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
}
setCursorOverrideActive((v) => !v);
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ft = fileTransferRef.current;
if (!ft) return;
const files = await ft.showFilePicker({
multiple: true
});
if (files.length === 0) return;
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
description: `${err}`
});
}
}}
>
Upload files
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
Terminate
</Button>
<label className="ml-2 flex items-center gap-2">
<input
type="checkbox"
checked={unicodeMode}
onChange={(e) => {
setUnicodeMode(e.target.checked);
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
</label>
</div>
{moduleReady && (
<iron-remote-desktop
ref={remoteElementRef}
verbose="true"
scale="fit"
flexcenter="true"
module={backendRef.current}
/>
)}
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

33
src/app/rdp/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { ip: string; port: number; authToken: string } | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
console.log("Fetched browser target:", target);
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <RdpClient target={target} error={error} />;
}

453
src/app/ssh/SshClient.tsx Normal file
View File

@@ -0,0 +1,453 @@
"use client";
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
import { GetBrowserTargetResponse } from "@server/routers/resource";
type FormState = {
username: string;
password: string;
privateKey: string;
};
type ConnectCredentials = {
username: string;
password?: string;
privateKey?: string;
certificate?: string;
};
export default function SshClient({
target,
error,
signedKeyData,
privateKey: signedPrivateKey
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
});
const fileInputRef = useRef<HTMLInputElement>(null);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
null
);
const wsRef = useRef<WebSocket | null>(null);
// Mount the terminal div once connected.
useEffect(() => {
if (!connected || !terminalRef.current) return;
let cancelled = false;
(async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links")
]);
if (cancelled || !terminalRef.current) return;
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: {
background: "#0d0d0d",
foreground: "#f0f0f0"
},
scrollback: 5000
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
xtermRef.current?.dispose();
};
}, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
username: signedKeyData.sshUsername,
privateKey: signedPrivateKey,
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null);
setConnecting(true);
if (!target) {
setConnectError("No target specified");
setConnecting(false);
return;
}
const username = override?.username ?? form.username;
const password = override?.password ?? form.password;
const privateKey = override?.privateKey ?? form.privateKey;
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
const url = new URL(proxyAddress);
url.searchParams.set("host", target.ip ?? "");
url.searchParams.set("port", String(target.port ?? 22));
url.searchParams.set("username", username);
url.searchParams.set("authToken", target.authToken ?? "");
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
ws.onopen = () => {
// Send credentials as the first frame so the proxy can complete
// SSH authentication before piping pty data.
ws.send(
JSON.stringify({
type: "auth",
password,
privateKey,
certificate
})
);
if (!override) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
}
setConnecting(false);
setConnected(true);
};
ws.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
data?: string;
error?: string;
};
if (msg.type === "data" && msg.data) {
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
);
}
} catch {
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => xtermRef.current?.write(t));
}
};
ws.onerror = () => {
setConnecting(false);
setConnected(false);
setConnectError("WebSocket connection failed");
};
ws.onclose = (evt) => {
setConnecting(false);
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
);
};
}
function disconnect() {
wsRef.current?.close();
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
// In push mode, show a connecting/connected state without the login form.
if (signedKeyData && signedPrivateKey) {
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectError
? connectError
: connecting
? "Connecting…"
: "Initializing…"}
</p>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
<div className="space-y-4">
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field label="Private Key (optional)" id="privateKey">
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
fileInputRef.current?.click()
}
>
Upload key file
</Button>
{form.privateKey && (
<button
type="button"
className="text-xs text-muted-foreground underline"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

92
src/app/ssh/page.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto";
function generateEphemeralKeyPair(): {
privateKeyPem: string;
publicKeyOpenSSH: string;
} {
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
crypto.generateKeyPairSync("ed25519");
const privateKeyPem = privKeyObj.export({
type: "pkcs8",
format: "pem"
}) as string;
// Build OpenSSH wire format: uint32-length-prefixed strings
const pubKeyDer = pubKeyObj.export({
type: "spki",
format: "der"
}) as Buffer;
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
function encodeField(b: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(b.length, 0);
return Buffer.concat([len, b]);
}
const keyBlob = Buffer.concat([
encodeField(Buffer.from("ssh-ed25519")),
encodeField(rawPubKey)
]);
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
return { privateKeyPem, publicKeyOpenSSH };
}
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export default async function SshPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let signedKeyData: SignSshKeyResponse | null = null;
let privateKey: string | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
if (target.pamMode === "push") {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resource: target.niceId
}
);
signedKeyData = res.data.data;
console.log("Received signed SSH key:", signedKeyData);
}
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return (
<SshClient
target={target}
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
/>
);
}

245
src/app/vnc/VncClient.tsx Normal file
View File

@@ -0,0 +1,245 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/resource";
type FormState = {
password: string;
};
export default function VncClient({
target,
error
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_vnc_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const connect = async () => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new (
target: HTMLElement,
url: string,
options?: Record<string, unknown>
) => unknown;
try {
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod;
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
description: `${err}`
});
return;
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
host: target.ip,
port: String(target.port),
authToken: target.authToken
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnected(true);
});
rfb.addEventListener(
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnected(false);
}
);
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
}
);
rfbRef.current = rfb;
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
<div className="space-y-4">
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (rfbRef.current) {
rfbRef.current.sendCtrlAltDel();
}
}}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard
?.readText()
.then((text) => {
rfbRef.current?.clipboardPasteFrom(text);
})
.catch(() => {});
}}
>
Paste clipboard
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
style={{ background: "#000" }}
/>
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

32
src/app/vnc/page.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <VncClient target={target} error={error} />;
}

View File

@@ -0,0 +1,146 @@
"use client";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type SingleSiteProps = {
multiSite?: false;
selectedSite: Selectedsite | null;
onSiteChange: (site: Selectedsite | null) => void;
};
type MultiSiteProps = {
multiSite: true;
selectedSites: Selectedsite[];
onSitesChange: (sites: Selectedsite[]) => void;
};
export type BrowserGatewayTargetFormProps = {
orgId: string;
destination: string;
defaultPort: number;
destinationPort: string;
onDestinationChange: (v: string) => void;
onDestinationPortChange: (v: string) => void;
learnMoreHref?: string;
} & (SingleSiteProps | MultiSiteProps);
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const siteSelector =
props.multiSite === true ? (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{formatMultiSitesSelectorLabel(
props.selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiSitesSelector
orgId={props.orgId}
selectedSites={props.selectedSites}
onSelectionChange={props.onSitesChange}
/>
</PopoverContent>
</Popover>
) : (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{props.selectedSite?.name ?? t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={props.orgId}
selectedSite={props.selectedSite}
onSelectSite={(site) => {
props.onSiteChange(site);
setSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("sites")}
</label>
{siteSelector}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
placeholder={props.defaultPort.toString()}
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
}
/>
</div>
</div>
{props.multiSite === true && props.selectedSites.length > 1 && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a
href={
props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/public/ssh"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
)}
</div>
);
}

View File

@@ -61,14 +61,14 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{client.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
<span>{t("connected")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
<span>{t("disconnected")}</span>
</div>
)}
</InfoSectionContent>

View File

@@ -2,7 +2,6 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
@@ -30,13 +29,21 @@ import {
ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
MoreHorizontal,
PlusIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { useEffect, useMemo, useState, useTransition } from "react";
import {
startTransition,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import type { PaginationState } from "@tanstack/react-table";
@@ -53,6 +60,10 @@ import {
} from "@app/components/ResourceSitesStatusCell";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -84,6 +95,11 @@ export type InternalResourceRow = {
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
function formatDestinationDisplay(row: InternalResourceRow): string {
@@ -141,7 +157,10 @@ export default function ClientResourcesTable({
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const [isRefreshing, startRefreshTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
useEffect(() => {
const interval = setInterval(() => {
@@ -167,7 +186,7 @@ export default function ClientResourcesTable({
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const refreshData = () => {
startTransition(() => {
startRefreshTransition(() => {
try {
router.refresh();
} catch (error) {
@@ -185,8 +204,8 @@ export default function ClientResourcesTable({
siteId: number
) => {
try {
await api.delete(`/site-resource/${resourceId}`).then(() => {
startTransition(() => {
startTransition(async () => {
await api.delete(`/site-resource/${resourceId}`).then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
@@ -254,296 +273,333 @@ export default function ClientResourcesTable({
);
}
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[]
>(() => {
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => {
const nameOrder = getSortDirection("name", searchParams);
const Icon =
nameOrder === "asc"
? ArrowDown01Icon
: nameOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
return (
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
className="p-3"
onClick={() => toggleSort("name")}
>
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
{t("name")}
<Icon className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
);
}
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "sites",
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
header: () => (
<Popover
open={siteFilterOpen}
onOpenChange={setSiteFilterOpen}
>
<div className="border-b p-1">
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
{t("standaloneHcFilterAnySite")}
<div className="flex items-center gap-2 min-w-0">
{t("sites")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceSitesStatusCell
orgId={resourceRow.orgId}
resourceSites={resourceRow.sites}
/>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) =>
handleFilterChange("mode", value)
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={display}
isLink={false}
displayText={display}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">
{t("resourcesTableDestination")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={resourceRow.alias}
text={display}
isLink={false}
displayText={resourceRow.alias}
displayText={display}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
);
}
if (resourceRow.mode === "http") {
const domainId = resourceRow.domainId;
const fullDomain = resourceRow.fullDomain;
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
const did =
build !== "oss" &&
resourceRow.ssl &&
domainId != null &&
domainId !== "" &&
fullDomain != null &&
fullDomain !== "";
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
return (
<div className="flex items-center gap-2 min-w-0">
{did ? (
<ResourceAccessCertIndicator
orgId={resourceRow.orgId}
domainId={domainId}
fullDomain={fullDomain}
/>
) : null}
<div className="">
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
</div>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",
friendlyName: t("resourcesTableAliasAddress"),
enableHiding: true,
header: () => (
<div className="flex items-center gap-2 p-3">
<span>{t("resourcesTableAliasAddress")}</span>
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
</div>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.aliasAddress ? (
<CopyToClipboard
text={resourceRow.aliasAddress}
isLink={false}
displayText={resourceRow.aliasAddress}
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels",
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
)
});
}
];
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
function handleFilterChange(
column: string,
@@ -638,7 +694,8 @@ export default function ClientResourcesTable({
enableColumnVisibility
columnVisibility={{
niceId: false,
aliasAddress: false
aliasAddress: false,
labels: false
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
@@ -674,3 +731,101 @@ export default function ClientResourcesTable({
</>
);
}
type ClientResourceLabelCellProps = {
resource: InternalResourceRow;
orgId: string;
};
function ClientResourceLabelCell({
resource,
orgId
}: ClientResourceLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = resource.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleResourceLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteResourceId: resource.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteResourceId: resource.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleResourceLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useState, useTransition } from "react";
import {
cleanForFQDN,
InternalResourceForm,
@@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
const [isSubmitting, startTransition] = useTransition();
async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true);
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
function handleSubmit(values: InternalResourceFormValues) {
startTransition(async () => {
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
data = { ...data, alias: aliasValue };
}
}
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
`/org/${orgId}/site-resource`,
{
await api.put<
AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
@@ -106,32 +106,30 @@ export default function CreateInternalResourceDialog({
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
}
);
});
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
}
return (

View File

@@ -0,0 +1,102 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
};
export function CreateOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess
}: CreateOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, data);
if (res.status === 201) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelCreateSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("createLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
onSubmit={(data) => {
startTransition(async () => createOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelCreate")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
label: {
name: string;
color: string;
labelId: number;
};
};
export function EditOrgLabelDialog({
open,
setOpen,
orgId,
onSuccess,
label
}: EditOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.patch<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/label/${label.labelId}`, data);
if (res.status === 200) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelEditSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="md:max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
<CredenzaDescription>
{t("editLabelDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<OrgLabelForm
defaultValue={label}
onSubmit={(data) => {
startTransition(async () => editOrgLabel(data));
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelEdit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -26,12 +26,12 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>

View File

@@ -140,14 +140,14 @@ export default function ExitNodesTable({
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>

View File

@@ -519,21 +519,21 @@ export default function HealthChecksTable({
const health = row.original.hcHealth;
if (health === "healthy") {
return (
<span className="text-green-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>{t("standaloneHcHealthStateHealthy")}</span>
</span>
);
} else if (health === "unhealthy") {
return (
<span className="text-red-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-red-500 rounded-full" />
<span>{t("standaloneHcHealthStateUnhealthy")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full" />
<span>{t("standaloneHcHealthStateUnknown")}</span>
</span>

View File

@@ -10,8 +10,11 @@ import {
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
ArrowRight,
ArrowUpDown,
@@ -19,12 +22,26 @@ import {
CircleSlash,
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon
ChevronsUpDownIcon,
PlusIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import {
startTransition,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "./ui/popover";
import { Badge } from "./ui/badge";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
@@ -53,6 +70,11 @@ export type ClientRow = {
archived?: boolean;
blocked?: boolean;
approvalState: "approved" | "pending" | "denied";
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type ClientTableProps = {
@@ -84,17 +106,21 @@ export default function MachineClientsTable({
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const [isRefreshing, startRefreshTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const defaultMachineColumnVisibility = {
subnet: false,
userId: false,
niceId: false
niceId: false,
labels: false
};
const refreshData = () => {
startTransition(() => {
startRefreshTransition(() => {
try {
router.refresh();
} catch (error) {
@@ -285,14 +311,14 @@ export default function MachineClientsTable({
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("connected")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("disconnected")}</span>
</span>
@@ -384,6 +410,24 @@ export default function MachineClientsTable({
}
];
if (isLabelFeatureEnabled) {
baseColumns.push({
id: "labels",
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell
client={row.original}
orgId={orgId}
/>
)
});
}
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
@@ -464,7 +508,7 @@ export default function MachineClientsTable({
}
return baseColumns;
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
@@ -591,3 +635,95 @@ export default function MachineClientsTable({
</>
);
}
type MachineClientLabelCellProps = {
client: ClientRow;
orgId: string;
};
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = client.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ clientId: client.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ clientId: client.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleClientLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import z from "zod";
import { Input } from "./ui/input";
import { useTranslations } from "use-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { LABEL_COLORS } from "./labels-selector";
const labelFormSchema = z.object({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData;
};
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS);
const randomColor =
colorValues[Math.floor(Math.random() * colorValues.length)];
const form = useForm({
resolver: zodResolver(labelFormSchema),
defaultValues: {
name: defaultValue?.name ?? "",
color: defaultValue?.color ?? randomColor
}
});
return (
<Form {...form}>
<form
id="org-label-form"
className="flex flex-col gap-4 px-0.5"
action={async () => {
if (await form.trigger()) {
onSubmit(form.getValues());
}
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelNameField")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("labelPlaceholder")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelColorField")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectColor")}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(LABEL_COLORS).map(
([color, value]) => (
<SelectItem
value={value}
key={color}
className="flex items-center gap-2"
>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>{color}</span>
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal,
PencilIcon,
PencilLineIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useActionState, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { LabelBadge } from "./label-badge";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
export type LabelRow = {
labelId: number;
name: string;
color: string;
};
type OrgLabelsTableProps = {
labels: LabelRow[];
pagination: PaginationState;
orgId: string;
rowCount: number;
};
export default function OrgLabelsTable({
labels,
orgId,
pagination,
rowCount
}: OrgLabelsTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const api = createApiClient(useEnvContext());
const t = useTranslations();
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => {
return <span className="p-3">{t("name")}</span>;
},
cell: ({ row }) => (
<div className="flex items-center gap-1.5 group">
<div
className="size-2.5 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": row.original.color
}}
/>
{row.original.name}
</div>
)
},
{
accessorKey: "actions",
enableHiding: false,
header: () => {
return <span className="p-3">{t("actions")}</span>;
},
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t("openMenu")}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsEditModalOpen(true);
}}
>
{t("edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
],
[searchParams, t]
);
function deleteLabel(label: LabelRow) {
startTransition(async () => {
await api
.delete(`/org/${orgId}/label/${label.labelId}`)
.catch((e) => {
toast({
variant: "destructive",
title: t("labelErrorDelete"),
description: formatAxiosError(e, t("labelErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
}
return (
<>
{selectedLabel && (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLabel(null);
}}
dialog={
<div className="space-y-2">
<p>{t("labelQuestionRemove")}</p>
<p>{t("labelMessageRemove")}</p>
</div>
}
buttonText={t("labelDeleteConfirm")}
onConfirm={async () => deleteLabel(selectedLabel)}
string={selectedLabel.name}
title={t("labelDelete")}
/>
<EditOrgLabelDialog
open={isEditModalOpen}
setOpen={setIsEditModalOpen}
orgId={orgId}
onSuccess={() =>
startTransition(() => router.refresh())
}
label={selectedLabel}
/>
</>
)}
<CreateOrgLabelDialog
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
orgId={orgId}
onSuccess={() => startTransition(() => router.refresh())}
/>
<ControlledDataTable
columns={columns}
rows={labels}
addButtonText={t("labelAdd")}
onAdd={() => setIsCreateModalOpen(true)}
tableId="org-labels-table"
searchPlaceholder={t("labelSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
/>
</>
);
}

View File

@@ -228,14 +228,14 @@ export default function PendingSitesTable({
) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
ShieldCheck,
ShieldOff,
Eye,
EyeOff,
CheckCircle2,
XCircle,
Clock
XCircle
} from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
@@ -32,19 +30,40 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
const showCertificate = !!(
resource.http &&
resource.domainId &&
resource.fullDomain &&
build != "oss"
);
const showType = !!(resource.http && resource.browserAccessType);
const showHealth =
!["ssh", "rdp", "vnc"].includes(resource.browserAccessType || "") &&
!!resource.health &&
resource.health !== "unknown";
const showVisibility = !resource.enabled;
const numSections = [
true, // URL or Protocol
true, // Authentication or Port
showType,
showCertificate,
showHealth,
showVisibility
].filter(Boolean).length;
return (
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections cols={resource.http && build != "oss" ? 6 : 5}>
<InfoSection>
<InfoSections cols={numSections}>
{/* <InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.niceId}
</span>
</InfoSectionContent>
</InfoSection>
</InfoSection> */}
{resource.http ? (
<>
<InfoSection>
@@ -62,6 +81,18 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
)}
</InfoSectionContent>
</InfoSection>
{showType && (
<InfoSection>
<InfoSectionTitle>
{t("type")}
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.browserAccessType!.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
@@ -84,24 +115,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
)}
</InfoSectionContent>
</InfoSection>
{/* {isEnabled && (
<InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent>
{isAvailable ? (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
) : (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>Offline</span>
</span>
)}
</InfoSectionContent>
</InfoSection>
)} */}
</>
) : (
<>
@@ -149,74 +162,69 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* </InfoSectionContent> */}
{/* </InfoSection> */}
{/* Certificate Status Column */}
{resource.http &&
resource.domainId &&
resource.fullDomain &&
build != "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={resource.fullDomain}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
{resource.health === "healthy" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("resourcesTableHealthy")}</span>
</div>
)}
{resource.health === "degraded" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span>
</div>
)}
{resource.health === "unhealthy" && (
<div className="flex items-center space-x-2">
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
<span>{t("resourcesTableUnhealthy")}</span>
</div>
)}
{(!resource.health ||
resource.health === "unknown") && (
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 flex-shrink-0" />
<span>{t("resourcesTableUnknown")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
{resource.enabled ? (
<div className="flex items-center space-x-2">
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("enabled")}</span>
</div>
) : (
{showCertificate && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId!}
fullDomain={resource.fullDomain!}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
)}
{showHealth && (
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
{resource.health === "healthy" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>
{t("resourcesTableHealthy")}
</span>
</div>
)}
{resource.health === "degraded" && (
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>
{t("resourcesTableDegraded")}
</span>
</div>
)}
{resource.health === "unhealthy" && (
<div className="flex items-center space-x-2">
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
<span>
{t("resourcesTableUnhealthy")}
</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
)}
{showVisibility && (
<InfoSection>
<InfoSectionTitle>
{t("visibility")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center space-x-2">
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
<span>{t("disabled")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>

View File

@@ -22,13 +22,24 @@ export function SettingsSectionHeader({
export function SettingsSectionForm({
children,
className
className,
variant = "compact"
}: {
children: React.ReactNode;
variant?: "half" | "compact";
className?: string;
}) {
return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
<div
className={cn(
variant === "half"
? "max-w-3xl space-y-4"
: "max-w-xl space-y-4",
className
)}
>
{children}
</div>
);
}

View File

@@ -34,12 +34,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{site.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>

View File

@@ -3,6 +3,16 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
@@ -14,9 +24,9 @@ import {
import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { type PaginationState } from "@tanstack/react-table";
import {
@@ -26,30 +36,35 @@ import {
ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal
MoreHorizontal,
PlusIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition, useEffect } from "react";
import {
startTransition,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { cn } from "@app/lib/cn";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
export type SiteRow = {
id: number;
nice: string;
@@ -66,6 +81,11 @@ export type SiteRow = {
exitNodeEndpoint?: string;
remoteExitNodeId?: string;
resourceCount: number;
labels?: Array<{
labelId: number;
name: string;
color: string;
}>;
};
type SitesTableProps = {
@@ -96,6 +116,9 @@ export default function SitesTable({
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const api = createApiClient(useEnvContext());
const t = useTranslations();
@@ -158,7 +181,8 @@ export default function SitesTable({
});
}
const columns: ExtendedColumnDef<SiteRow>[] = [
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
const cols: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
@@ -226,14 +250,14 @@ export default function SitesTable({
) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>
@@ -366,7 +390,7 @@ export default function SitesTable({
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-0 font-normal"
className="flex h-8 items-center gap-2 px-2 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
@@ -437,7 +461,7 @@ export default function SitesTable({
header: () => {
return <span className="p-3">{t("address")}</span>;
},
cell: ({ row }: { row: any }) => {
cell: ({ row }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
@@ -488,16 +512,6 @@ export default function SitesTable({
{t("sitesTableViewPrivateResources")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
@@ -512,7 +526,24 @@ export default function SitesTable({
);
}
}
];
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
accessorKey: "labels",
header: () => (
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} />
)
});
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
@@ -622,7 +653,8 @@ export default function SitesTable({
niceId: false,
nice: false,
exitNode: false,
address: false
address: false,
labels: false
}}
enableColumnVisibility
stickyLeftColumn="name"
@@ -631,3 +663,102 @@ export default function SitesTable({
</>
);
}
type SiteLabelCellProps = {
site: SiteRow;
orgId: string;
};
function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = site.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleSiteLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => {
try {
if (action === "attach") {
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteId: site.id }
);
} else {
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
await api.put(
`/org/${orgId}/label/${label.labelId}/detach`,
{ siteId: site.id }
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
});
}
return (
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleSiteLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

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