mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-03 08:39:09 +00:00
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ed13b41d9 | ||
|
|
b80757a129 | ||
|
|
13ddf30781 | ||
|
|
4ecca88856 | ||
|
|
4f154d212e | ||
|
|
981d777a65 | ||
|
|
dd13758085 | ||
|
|
3d8153aeb1 | ||
|
|
9ffa391416 | ||
|
|
afc19f192b | ||
|
|
5587bd9d59 | ||
|
|
b5f8e8feb2 | ||
|
|
9bd66fa306 | ||
|
|
fea4d43920 | ||
|
|
d414617f9d | ||
|
|
1d7e55bf98 | ||
|
|
bc45e16109 | ||
|
|
4f1dc19569 | ||
|
|
1af938d7ea | ||
|
|
fc924f707c | ||
|
|
6e7ba1dc52 | ||
|
|
3e01bfef7d | ||
|
|
d8b662496b | ||
|
|
e0de003c2c | ||
|
|
6e35c182b0 | ||
|
|
2479a3c53c | ||
|
|
6b609bb078 | ||
|
|
9c21e3da16 | ||
|
|
7ccde11e3e | ||
|
|
56b0185c8f | ||
|
|
8b47b2aabe | ||
|
|
416fd914cb | ||
|
|
16653dd524 | ||
|
|
e2d3d172af | ||
|
|
137d6c2523 | ||
|
|
1a976c78ef | ||
|
|
e309a125f5 | ||
|
|
2bdb1ddb6f | ||
|
|
8ff588407c | ||
|
|
c2e06725a8 | ||
|
|
bb43e0c325 | ||
|
|
35ea01610a | ||
|
|
79eefc0ac7 | ||
|
|
3a781f9ac4 | ||
|
|
cc1e551f43 | ||
|
|
68191d5921 | ||
|
|
2b3d065650 | ||
|
|
7ae80d2cad | ||
|
|
acf08e3ef6 | ||
|
|
6f50fb8a4f | ||
|
|
a5b203af27 | ||
|
|
443b53ee37 | ||
|
|
e033c10021 | ||
|
|
ad4c44c325 | ||
|
|
4aef7ca8d5 | ||
|
|
f892acbc4c | ||
|
|
9010ed6237 | ||
|
|
9f29657570 | ||
|
|
1b13132845 | ||
|
|
553fda265c | ||
|
|
0f79826535 | ||
|
|
14438bd2b4 | ||
|
|
c4445c329f | ||
|
|
5c032ee0c3 | ||
|
|
d3d5a1c204 | ||
|
|
809bb4a7b4 | ||
|
|
e8f763a77f | ||
|
|
3ad4a76f03 | ||
|
|
b133593ea2 | ||
|
|
43fb06084f | ||
|
|
9de39dbe42 | ||
|
|
c98d61a8fb | ||
|
|
fccff9c23a | ||
|
|
e02fa7c148 | ||
|
|
a21029582e | ||
|
|
9ef7faace7 | ||
|
|
3d5ae9dd5c | ||
|
|
6072ee93fa | ||
|
|
7f7f6eeaea | ||
|
|
1b4884afd8 | ||
|
|
0c0ad7029f | ||
|
|
10f1437496 | ||
|
|
c44c1a5518 | ||
|
|
48110ccda3 | ||
|
|
e94f21bc05 | ||
|
|
65f8a414be | ||
|
|
8dad38775c | ||
|
|
0d14cb853e | ||
|
|
778e6bf623 | ||
|
|
5a960649db | ||
|
|
23a7688789 | ||
|
|
0e3b6b90b7 | ||
|
|
872bb557c2 | ||
|
|
9125a7bccb | ||
|
|
5a0a8893e8 | ||
|
|
abe76e5002 | ||
|
|
474b9a685d | ||
|
|
97631c068c | ||
|
|
98c77ad7e2 | ||
|
|
3915df3200 | ||
|
|
9b98acb553 | ||
|
|
a767a31c21 | ||
|
|
f2d4c2f83c | ||
|
|
25fed23758 | ||
|
|
5cb3fa1127 | ||
|
|
deac26bad2 | ||
|
|
c7747fd4b4 | ||
|
|
1aaad43871 | ||
|
|
143175bde7 | ||
|
|
9f55d6b20a | ||
|
|
4366ca5836 | ||
|
|
9cb95576d0 | ||
|
|
d5307adef0 | ||
|
|
3d857c3b52 | ||
|
|
a012369f83 | ||
|
|
9cee3d9c79 | ||
|
|
8257dca340 | ||
|
|
5e0a1cf9c5 | ||
|
|
b3ec9dfda2 | ||
|
|
93d4f60314 | ||
|
|
769d20cea1 | ||
|
|
124ba208de | ||
|
|
ba99614d58 | ||
|
|
27db77bca4 | ||
|
|
29b924230f | ||
|
|
8eb3f6aacc | ||
|
|
7f07ccea44 | ||
|
|
c13bfc709f | ||
|
|
6fc54bcc9e | ||
|
|
aab0471b6b | ||
|
|
de684b212f | ||
|
|
fbd3802e46 | ||
|
|
4e842a660a | ||
|
|
ce6b609ca2 | ||
|
|
78369b6f6a | ||
|
|
ea43bf97c7 | ||
|
|
72bc26f0f8 | ||
|
|
2ec2295cd6 | ||
|
|
a0a369dc43 | ||
|
|
d0157ea7a5 | ||
|
|
d89f5279bf | ||
|
|
744305ab39 | ||
|
|
ba9048a377 | ||
|
|
ff089ec6d7 | ||
|
|
dc4f9a9bd1 | ||
|
|
e867de023a | ||
|
|
e00c3f2193 | ||
|
|
8c30995228 | ||
|
|
3ba65a3311 | ||
|
|
c5914dc0c0 | ||
|
|
30f3ab11b2 | ||
|
|
66b01b764f | ||
|
|
ee7e7778b6 | ||
|
|
0d0c43f72b | ||
|
|
83f36bce9d | ||
|
|
2466d24c1a | ||
|
|
2f34def4d7 | ||
|
|
8e8f992876 | ||
|
|
1d9ed9d219 | ||
|
|
616fb9c8e9 | ||
|
|
a2ab7191e5 | ||
|
|
7a31292ec7 | ||
|
|
196fbbe334 | ||
|
|
5bb5aeff36 | ||
|
|
2ada05b286 | ||
|
|
87f23f582c | ||
|
|
29a52f6ac4 | ||
|
|
790f7083e2 | ||
|
|
5c851e82ff | ||
|
|
854f638da3 | ||
|
|
4842648e7b | ||
|
|
8f152bdf9f | ||
|
|
d003436179 | ||
|
|
9776ef43ea | ||
|
|
e2c4a906c4 | ||
|
|
27e8250cd1 | ||
|
|
0d84b7af6e | ||
|
|
b961271aa6 | ||
|
|
b505cc60b0 | ||
|
|
955f927c59 | ||
|
|
4beed9d464 | ||
|
|
228481444f | ||
|
|
02cd2cfb17 | ||
|
|
d218a4bbc3 | ||
|
|
4bd1c4e0c6 | ||
|
|
cfde4e7443 | ||
|
|
f58cf68f7c | ||
|
|
08e43400e4 | ||
|
|
46d60bd090 | ||
|
|
5641a2aa31 | ||
|
|
0abc561bb8 |
188
.github/workflows/cicd.yml
vendored
188
.github/workflows/cicd.yml
vendored
@@ -24,9 +24,35 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
pre-run:
|
||||||
name: Build and Release
|
runs-on: ubuntu-latest
|
||||||
runs-on: [self-hosted, linux, x64]
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v2
|
||||||
|
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 }}
|
||||||
|
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_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
|
# Job-level timeout to avoid runaway or stuck runs
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
env:
|
env:
|
||||||
@@ -38,11 +64,17 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Monitor storage space
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
run: |
|
||||||
|
THRESHOLD=75
|
||||||
- name: Set up Docker Buildx
|
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
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: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
@@ -50,6 +82,103 @@ jobs:
|
|||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- 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-release-arm tag=$TAG
|
||||||
|
echo "Built & pushed ARM64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
release-amd:
|
||||||
|
name: Build and Release (AMD64)
|
||||||
|
runs-on: [self-hosted, linux, x64, 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
|
||||||
|
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
|
||||||
|
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
|
- 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: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
|
with:
|
||||||
|
registry: docker.io
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- 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 - AMD64)
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
make build-release-amd tag=$TAG
|
||||||
|
echo "Built & pushed AMD64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
sign-and-package:
|
||||||
|
name: Sign and Package
|
||||||
|
runs-on: [self-hosted, linux, x64, us-east-1]
|
||||||
|
needs: [release-arm, release-amd]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
needs.release-arm.result == 'success' &&
|
||||||
|
needs.release-amd.result == 'success'
|
||||||
|
}}
|
||||||
|
# Job-level timeout to avoid runaway or stuck runs
|
||||||
|
timeout-minutes: 120
|
||||||
|
env:
|
||||||
|
# Target images
|
||||||
|
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
|
||||||
|
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
@@ -96,7 +225,7 @@ jobs:
|
|||||||
- name: Build installer
|
- name: Build installer
|
||||||
working-directory: install
|
working-directory: install
|
||||||
run: |
|
run: |
|
||||||
make go-build-release
|
make go-build-release
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
- name: Upload artifacts from /install/bin
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
@@ -104,13 +233,6 @@ jobs:
|
|||||||
name: install-bin
|
name: install-bin
|
||||||
path: install/bin/
|
path: install/bin/
|
||||||
|
|
||||||
- name: Build and push Docker images (Docker Hub)
|
|
||||||
run: |
|
|
||||||
TAG=${{ env.TAG }}
|
|
||||||
make build-release tag=$TAG
|
|
||||||
echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Install skopeo + jq
|
- name: Install skopeo + jq
|
||||||
# skopeo: copy/inspect images between registries
|
# skopeo: copy/inspect images between registries
|
||||||
# jq: JSON parsing tool used to extract digest values
|
# jq: JSON parsing tool used to extract digest values
|
||||||
@@ -127,15 +249,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy tag from Docker Hub to GHCR
|
- name: Copy tag from Docker Hub to GHCR
|
||||||
# Mirror the already-built image (all architectures) to GHCR so we can sign it
|
# Mirror the already-built image (all architectures) to GHCR so we can sign it
|
||||||
|
# Wait a bit for both architectures to be available in Docker Hub manifest
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG=${{ env.TAG }}
|
TAG=${{ env.TAG }}
|
||||||
|
echo "Waiting for multi-arch manifest to be ready..."
|
||||||
|
sleep 30
|
||||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
||||||
skopeo copy --all --retry-times 3 \
|
skopeo copy --all --retry-times 3 \
|
||||||
docker://$DOCKERHUB_IMAGE:$TAG \
|
docker://$DOCKERHUB_IMAGE:$TAG \
|
||||||
docker://$GHCR_IMAGE:$TAG
|
docker://$GHCR_IMAGE:$TAG
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
@@ -185,3 +310,32 @@ jobs:
|
|||||||
"${REF}" -o text
|
"${REF}" -o text
|
||||||
done
|
done
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
post-run:
|
||||||
|
needs: [pre-run, release-arm, release-amd, sign-and-package]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
always() &&
|
||||||
|
needs.pre-run.result == 'success' &&
|
||||||
|
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') &&
|
||||||
|
(needs.release-amd.result == 'success' || needs.release-amd.result == 'skipped' || needs.release-amd.result == 'failure') &&
|
||||||
|
(needs.sign-and-package.result == 'success' || needs.sign-and-package.result == 'skipped' || needs.sign-and-package.result == 'failure')
|
||||||
|
}}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v2
|
||||||
|
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 }}
|
||||||
|
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
||||||
|
echo "EC2 instances stopped"
|
||||||
|
|||||||
39
.github/workflows/restart-runners.yml
vendored
Normal file
39
.github/workflows/restart-runners.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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@v2
|
||||||
|
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"
|
||||||
29
.github/workflows/test.yml
vendored
29
.github/workflows/test.yml
vendored
@@ -12,11 +12,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
@@ -57,8 +58,26 @@ jobs:
|
|||||||
echo "App failed to start"
|
echo "App failed to start"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
build-sqlite:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
|
- name: Copy config file
|
||||||
|
run: cp config/config.example.yml config/config.yml
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make build-sqlite
|
run: make dev-build-sqlite
|
||||||
|
|
||||||
|
build-postgres:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
|
- name: Copy config file
|
||||||
|
run: cp config/config.example.yml config/config.yml
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -43,23 +43,25 @@ RUN test -f dist/server.mjs
|
|||||||
|
|
||||||
RUN npm run build:cli
|
RUN npm run build:cli
|
||||||
|
|
||||||
|
# Prune dev dependencies and clean up to prepare for copy to runner
|
||||||
|
RUN npm prune --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Curl used for the health checks
|
# Only curl and tzdata needed at runtime - no build tools!
|
||||||
# Python and build tools needed for better-sqlite3 native compilation
|
RUN apk add --no-cache curl tzdata
|
||||||
RUN apk add --no-cache curl tzdata python3 make g++
|
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||||
COPY package*.json ./
|
# This includes the compiled native modules like better-sqlite3
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/init ./dist/init
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
||||||
|
|||||||
119
Makefile
119
Makefile
@@ -1,8 +1,13 @@
|
|||||||
.PHONY: build build-pg build-release build-arm build-x86 test clean
|
.PHONY: build build-pg build-release build-release-arm build-release-amd build-arm build-x86 test clean
|
||||||
|
|
||||||
major_tag := $(shell echo $(tag) | cut -d. -f1)
|
major_tag := $(shell echo $(tag) | cut -d. -f1)
|
||||||
minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
|
minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
|
||||||
build-release:
|
|
||||||
|
.PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
|
||||||
|
|
||||||
|
build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
|
||||||
|
|
||||||
|
build-sqlite:
|
||||||
@if [ -z "$(tag)" ]; then \
|
@if [ -z "$(tag)" ]; then \
|
||||||
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
@@ -16,6 +21,12 @@ build-release:
|
|||||||
--tag fosrl/pangolin:$(minor_tag) \
|
--tag fosrl/pangolin:$(minor_tag) \
|
||||||
--tag fosrl/pangolin:$(tag) \
|
--tag fosrl/pangolin:$(tag) \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
|
build-postgresql:
|
||||||
|
@if [ -z "$(tag)" ]; then \
|
||||||
|
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg BUILD=oss \
|
--build-arg BUILD=oss \
|
||||||
--build-arg DATABASE=pg \
|
--build-arg DATABASE=pg \
|
||||||
@@ -25,6 +36,12 @@ build-release:
|
|||||||
--tag fosrl/pangolin:postgresql-$(minor_tag) \
|
--tag fosrl/pangolin:postgresql-$(minor_tag) \
|
||||||
--tag fosrl/pangolin:postgresql-$(tag) \
|
--tag fosrl/pangolin:postgresql-$(tag) \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
|
build-ee-sqlite:
|
||||||
|
@if [ -z "$(tag)" ]; then \
|
||||||
|
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg BUILD=enterprise \
|
--build-arg BUILD=enterprise \
|
||||||
--build-arg DATABASE=sqlite \
|
--build-arg DATABASE=sqlite \
|
||||||
@@ -34,6 +51,12 @@ build-release:
|
|||||||
--tag fosrl/pangolin:ee-$(minor_tag) \
|
--tag fosrl/pangolin:ee-$(minor_tag) \
|
||||||
--tag fosrl/pangolin:ee-$(tag) \
|
--tag fosrl/pangolin:ee-$(tag) \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
|
build-ee-postgresql:
|
||||||
|
@if [ -z "$(tag)" ]; then \
|
||||||
|
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg BUILD=enterprise \
|
--build-arg BUILD=enterprise \
|
||||||
--build-arg DATABASE=pg \
|
--build-arg DATABASE=pg \
|
||||||
@@ -44,6 +67,94 @@ build-release:
|
|||||||
--tag fosrl/pangolin:ee-postgresql-$(tag) \
|
--tag fosrl/pangolin:ee-postgresql-$(tag) \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
|
build-release-arm:
|
||||||
|
@if [ -z "$(tag)" ]; then \
|
||||||
|
echo "Error: tag is required. Usage: make build-release-arm tag=<tag>"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \
|
||||||
|
MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=oss \
|
||||||
|
--build-arg DATABASE=sqlite \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--tag fosrl/pangolin:latest \
|
||||||
|
--tag fosrl/pangolin:$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:$(tag) \
|
||||||
|
--push . && \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=oss \
|
||||||
|
--build-arg DATABASE=pg \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--tag fosrl/pangolin:postgresql-latest \
|
||||||
|
--tag fosrl/pangolin:postgresql-$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:postgresql-$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:postgresql-$(tag) \
|
||||||
|
--push . && \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=enterprise \
|
||||||
|
--build-arg DATABASE=sqlite \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--tag fosrl/pangolin:ee-latest \
|
||||||
|
--tag fosrl/pangolin:ee-$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-$(tag) \
|
||||||
|
--push . && \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=enterprise \
|
||||||
|
--build-arg DATABASE=pg \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-latest \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-$(tag) \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
build-release-amd:
|
||||||
|
@if [ -z "$(tag)" ]; then \
|
||||||
|
echo "Error: tag is required. Usage: make build-release-amd tag=<tag>"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \
|
||||||
|
MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=oss \
|
||||||
|
--build-arg DATABASE=sqlite \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--tag fosrl/pangolin:latest \
|
||||||
|
--tag fosrl/pangolin:$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:$(tag) \
|
||||||
|
--push . && \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=oss \
|
||||||
|
--build-arg DATABASE=pg \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--tag fosrl/pangolin:postgresql-latest \
|
||||||
|
--tag fosrl/pangolin:postgresql-$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:postgresql-$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:postgresql-$(tag) \
|
||||||
|
--push . && \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=enterprise \
|
||||||
|
--build-arg DATABASE=sqlite \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--tag fosrl/pangolin:ee-latest \
|
||||||
|
--tag fosrl/pangolin:ee-$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-$(tag) \
|
||||||
|
--push . && \
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg BUILD=enterprise \
|
||||||
|
--build-arg DATABASE=pg \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-latest \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG \
|
||||||
|
--tag fosrl/pangolin:ee-postgresql-$(tag) \
|
||||||
|
--push .
|
||||||
|
|
||||||
build-rc:
|
build-rc:
|
||||||
@if [ -z "$(tag)" ]; then \
|
@if [ -z "$(tag)" ]; then \
|
||||||
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||||
@@ -80,10 +191,10 @@ build-arm:
|
|||||||
build-x86:
|
build-x86:
|
||||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
build-sqlite:
|
dev-build-sqlite:
|
||||||
docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest .
|
docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
build-pg:
|
dev-build-pg:
|
||||||
docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest .
|
docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
[](https://pangolin.net/slack)
|
[](https://pangolin.net/slack)
|
||||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||||

|

|
||||||
[](https://www.youtube.com/@fossorial-app)
|
[](https://www.youtube.com/@pangolin-net)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
36
cli/commands/clearExitNodes.ts
Normal file
36
cli/commands/clearExitNodes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { CommandModule } from "yargs";
|
||||||
|
import { db, exitNodes } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
type ClearExitNodesArgs = { };
|
||||||
|
|
||||||
|
export const clearExitNodes: CommandModule<
|
||||||
|
{},
|
||||||
|
ClearExitNodesArgs
|
||||||
|
> = {
|
||||||
|
command: "clear-exit-nodes",
|
||||||
|
describe:
|
||||||
|
"Clear all exit nodes from the database",
|
||||||
|
// no args
|
||||||
|
builder: (yargs) => {
|
||||||
|
return yargs;
|
||||||
|
},
|
||||||
|
handler: async (argv: {}) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
console.log(`Clearing all exit nodes from the database`);
|
||||||
|
|
||||||
|
// Delete all exit nodes
|
||||||
|
const deletedCount = await db
|
||||||
|
.delete(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all
|
||||||
|
|
||||||
|
console.log(`Deleted ${deletedCount.length} exit node(s) from the database`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
284
cli/commands/rotateServerSecret.ts
Normal file
284
cli/commands/rotateServerSecret.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { CommandModule } from "yargs";
|
||||||
|
import { db, idpOidcConfig, licenseKey } from "@server/db";
|
||||||
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import fs from "fs";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
type RotateServerSecretArgs = {
|
||||||
|
oldSecret: string;
|
||||||
|
newSecret: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rotateServerSecret: CommandModule<
|
||||||
|
{},
|
||||||
|
RotateServerSecretArgs
|
||||||
|
> = {
|
||||||
|
command: "rotate-server-secret",
|
||||||
|
describe:
|
||||||
|
"Rotate the server secret by decrypting all encrypted values with the old secret and re-encrypting with a new secret",
|
||||||
|
builder: (yargs) => {
|
||||||
|
return yargs
|
||||||
|
.option("oldSecret", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
describe: "The current server secret (for verification)"
|
||||||
|
})
|
||||||
|
.option("newSecret", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
describe: "The new server secret to use"
|
||||||
|
})
|
||||||
|
.option("force", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe:
|
||||||
|
"Force rotation even if the old secret doesn't match the config file. " +
|
||||||
|
"Use this if you know the old secret is correct but the config file is out of sync. " +
|
||||||
|
"WARNING: This will attempt to decrypt all values with the provided old secret. " +
|
||||||
|
"If the old secret is incorrect, the rotation will fail or corrupt data."
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async (argv: {
|
||||||
|
oldSecret: string;
|
||||||
|
newSecret: string;
|
||||||
|
force?: boolean;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// Determine which config file exists
|
||||||
|
const configPath = fs.existsSync(configFilePath1)
|
||||||
|
? configFilePath1
|
||||||
|
: fs.existsSync(configFilePath2)
|
||||||
|
? configFilePath2
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!configPath) {
|
||||||
|
console.error(
|
||||||
|
"Error: Config file not found. Expected config.yml or config.yaml in the config directory."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current config
|
||||||
|
const configContent = fs.readFileSync(configPath, "utf8");
|
||||||
|
const config = yaml.load(configContent) as any;
|
||||||
|
|
||||||
|
if (!config?.server?.secret) {
|
||||||
|
console.error(
|
||||||
|
"Error: No server secret found in config file. Cannot rotate."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configSecret = config.server.secret;
|
||||||
|
const oldSecret = argv.oldSecret;
|
||||||
|
const newSecret = argv.newSecret;
|
||||||
|
const force = argv.force || false;
|
||||||
|
|
||||||
|
// Verify that the provided old secret matches the one in config
|
||||||
|
if (configSecret !== oldSecret) {
|
||||||
|
if (!force) {
|
||||||
|
console.error(
|
||||||
|
"Error: The provided old secret does not match the secret in the config file."
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"\nIf you are certain the old secret is correct and the config file is out of sync,"
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"you can use the --force flag to bypass this check."
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"\nWARNING: Using --force with an incorrect old secret will cause the rotation to fail"
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"or corrupt encrypted data. Only use --force if you are absolutely certain."
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"\nWARNING: Using --force flag. Bypassing old secret verification."
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
"The provided old secret does not match the config file, but proceeding anyway."
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
"If the old secret is incorrect, this operation will fail or corrupt data.\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new secret
|
||||||
|
if (newSecret.length < 8) {
|
||||||
|
console.error(
|
||||||
|
"Error: New secret must be at least 8 characters long"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldSecret === newSecret) {
|
||||||
|
console.error("Error: New secret must be different from old secret");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Starting server secret rotation...");
|
||||||
|
console.log("This will decrypt and re-encrypt all encrypted values in the database.");
|
||||||
|
|
||||||
|
// Read all data first
|
||||||
|
console.log("\nReading encrypted data from database...");
|
||||||
|
const idpConfigs = await db.select().from(idpOidcConfig);
|
||||||
|
const licenseKeys = await db.select().from(licenseKey);
|
||||||
|
|
||||||
|
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
|
|
||||||
|
// Prepare all decrypted and re-encrypted values
|
||||||
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
|
|
||||||
|
type IdpUpdate = {
|
||||||
|
idpOauthConfigId: number;
|
||||||
|
encryptedClientId: string;
|
||||||
|
encryptedClientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LicenseKeyUpdate = {
|
||||||
|
oldLicenseKeyId: string;
|
||||||
|
newLicenseKeyId: string;
|
||||||
|
encryptedToken: string;
|
||||||
|
encryptedInstanceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
|
|
||||||
|
// Process idpOidcConfig entries
|
||||||
|
for (const idpConfig of idpConfigs) {
|
||||||
|
try {
|
||||||
|
// Decrypt with old secret
|
||||||
|
const decryptedClientId = decrypt(idpConfig.clientId, oldSecret);
|
||||||
|
const decryptedClientSecret = decrypt(
|
||||||
|
idpConfig.clientSecret,
|
||||||
|
oldSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-encrypt with new secret
|
||||||
|
const encryptedClientId = encrypt(decryptedClientId, newSecret);
|
||||||
|
const encryptedClientSecret = encrypt(
|
||||||
|
decryptedClientSecret,
|
||||||
|
newSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
idpUpdates.push({
|
||||||
|
idpOauthConfigId: idpConfig.idpOauthConfigId,
|
||||||
|
encryptedClientId,
|
||||||
|
encryptedClientSecret
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing IdP config ${idpConfig.idpOauthConfigId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process licenseKey entries
|
||||||
|
for (const key of licenseKeys) {
|
||||||
|
try {
|
||||||
|
// Decrypt with old secret
|
||||||
|
const decryptedLicenseKeyId = decrypt(key.licenseKeyId, oldSecret);
|
||||||
|
const decryptedToken = decrypt(key.token, oldSecret);
|
||||||
|
const decryptedInstanceId = decrypt(key.instanceId, oldSecret);
|
||||||
|
|
||||||
|
// Re-encrypt with new secret
|
||||||
|
const encryptedLicenseKeyId = encrypt(
|
||||||
|
decryptedLicenseKeyId,
|
||||||
|
newSecret
|
||||||
|
);
|
||||||
|
const encryptedToken = encrypt(decryptedToken, newSecret);
|
||||||
|
const encryptedInstanceId = encrypt(
|
||||||
|
decryptedInstanceId,
|
||||||
|
newSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
licenseKeyUpdates.push({
|
||||||
|
oldLicenseKeyId: key.licenseKeyId,
|
||||||
|
newLicenseKeyId: encryptedLicenseKeyId,
|
||||||
|
encryptedToken,
|
||||||
|
encryptedInstanceId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing license key ${key.licenseKeyId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform all database updates in a single transaction
|
||||||
|
console.log("\nUpdating database in transaction...");
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
// Update idpOidcConfig entries
|
||||||
|
for (const update of idpUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(idpOidcConfig)
|
||||||
|
.set({
|
||||||
|
clientId: update.encryptedClientId,
|
||||||
|
clientSecret: update.encryptedClientSecret
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
idpOidcConfig.idpOauthConfigId,
|
||||||
|
update.idpOauthConfigId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update licenseKey entries (delete old, insert new)
|
||||||
|
for (const update of licenseKeyUpdates) {
|
||||||
|
// Delete old entry
|
||||||
|
await trx
|
||||||
|
.delete(licenseKey)
|
||||||
|
.where(eq(licenseKey.licenseKeyId, update.oldLicenseKeyId));
|
||||||
|
|
||||||
|
// Insert new entry with re-encrypted values
|
||||||
|
await trx.insert(licenseKey).values({
|
||||||
|
licenseKeyId: update.newLicenseKeyId,
|
||||||
|
token: update.encryptedToken,
|
||||||
|
instanceId: update.encryptedInstanceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||||
|
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
|
||||||
|
|
||||||
|
// Update config file with new secret
|
||||||
|
console.log("\nUpdating config file...");
|
||||||
|
config.server.secret = newSecret;
|
||||||
|
const newConfigContent = yaml.dump(config, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1
|
||||||
|
});
|
||||||
|
fs.writeFileSync(configPath, newConfigContent, "utf8");
|
||||||
|
|
||||||
|
console.log(`Updated config file: ${configPath}`);
|
||||||
|
|
||||||
|
console.log("\nServer secret rotation completed successfully!");
|
||||||
|
console.log(`\nSummary:`);
|
||||||
|
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
|
||||||
|
console.log(` - License keys: ${licenseKeyUpdates.length}`);
|
||||||
|
console.log(
|
||||||
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rotating server secret:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -4,10 +4,14 @@ import yargs from "yargs";
|
|||||||
import { hideBin } from "yargs/helpers";
|
import { hideBin } from "yargs/helpers";
|
||||||
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
||||||
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
||||||
|
import { clearExitNodes } from "./commands/clearExitNodes";
|
||||||
|
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
.command(setAdminCredentials)
|
.command(setAdminCredentials)
|
||||||
.command(resetUserSecurityKeys)
|
.command(resetUserSecurityKeys)
|
||||||
|
.command(clearExitNodes)
|
||||||
|
.command(rotateServerSecret)
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ services:
|
|||||||
PARSERS: crowdsecurity/whitelists
|
PARSERS: crowdsecurity/whitelists
|
||||||
ENROLL_TAGS: docker
|
ENROLL_TAGS: docker
|
||||||
healthcheck:
|
healthcheck:
|
||||||
interval: 10s
|
test:
|
||||||
retries: 15
|
- CMD
|
||||||
timeout: 10s
|
- cscli
|
||||||
test: ["CMD", "cscli", "capi", "status"]
|
- lapi
|
||||||
|
- status
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=false" # Disable traefik for crowdsec
|
- "traefik.enable=false" # Disable traefik for crowdsec
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ http:
|
|||||||
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
crowdsecAppsecUnreachableBlock: true # Block on unreachable
|
||||||
crowdsecAppsecBodyLimit: 10485760
|
crowdsecAppsecBodyLimit: 10485760
|
||||||
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
|
||||||
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
crowdsecLapiHost: crowdsec:8080 # CrowdSec
|
||||||
crowdsecLapiScheme: http # CrowdSec API scheme
|
crowdsecLapiScheme: http # CrowdSec API scheme
|
||||||
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
|
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
|
||||||
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
|
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
|
||||||
@@ -106,4 +106,13 @@ http:
|
|||||||
api-service:
|
api-service:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
- url: "http://pangolin:3000" # API/WebSocket server
|
- url: "http://pangolin:3000" # API/WebSocket server
|
||||||
|
|
||||||
|
tcp:
|
||||||
|
serversTransports:
|
||||||
|
pp-transport-v1:
|
||||||
|
proxyProtocol:
|
||||||
|
version: 1
|
||||||
|
pp-transport-v2:
|
||||||
|
proxyProtocol:
|
||||||
|
version: 2
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func installDocker() error {
|
|||||||
case strings.Contains(osRelease, "ID=ubuntu"):
|
case strings.Contains(osRelease, "ID=ubuntu"):
|
||||||
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
apt-get install -y apt-transport-https ca-certificates curl &&
|
apt-get install -y apt-transport-https ca-certificates curl gpg &&
|
||||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
||||||
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
@@ -82,7 +82,7 @@ func installDocker() error {
|
|||||||
case strings.Contains(osRelease, "ID=debian"):
|
case strings.Contains(osRelease, "ID=debian"):
|
||||||
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
apt-get install -y apt-transport-https ca-certificates curl &&
|
apt-get install -y apt-transport-https ca-certificates curl gpg &&
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
|
||||||
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"createAccount": "Create Account",
|
"createAccount": "Create Account",
|
||||||
"viewSettings": "View settings",
|
"viewSettings": "View Settings",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
@@ -51,6 +51,9 @@
|
|||||||
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
|
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
|
||||||
"siteManageSites": "Manage Sites",
|
"siteManageSites": "Manage Sites",
|
||||||
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
||||||
|
"sitesBannerTitle": "Connect Any Network",
|
||||||
|
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
|
||||||
|
"sitesBannerButtonText": "Install Site",
|
||||||
"siteCreate": "Create Site",
|
"siteCreate": "Create Site",
|
||||||
"siteCreateDescription2": "Follow the steps below to create and connect a new site",
|
"siteCreateDescription2": "Follow the steps below to create and connect a new site",
|
||||||
"siteCreateDescription": "Create a new site to start connecting resources",
|
"siteCreateDescription": "Create a new site to start connecting resources",
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
"siteTunnelDescription": "Determine how you want to connect to the site",
|
"siteTunnelDescription": "Determine how you want to connect to the site",
|
||||||
"siteNewtCredentials": "Credentials",
|
"siteNewtCredentials": "Credentials",
|
||||||
"siteNewtCredentialsDescription": "This is how the site will authenticate with the server",
|
"siteNewtCredentialsDescription": "This is how the site will authenticate with the server",
|
||||||
|
"remoteNodeCredentialsDescription": "This is how the remote node will authenticate with the server",
|
||||||
"siteCredentialsSave": "Save the Credentials",
|
"siteCredentialsSave": "Save the Credentials",
|
||||||
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
"siteInfo": "Site Information",
|
"siteInfo": "Site Information",
|
||||||
@@ -146,8 +150,12 @@
|
|||||||
"shareErrorSelectResource": "Please select a resource",
|
"shareErrorSelectResource": "Please select a resource",
|
||||||
"proxyResourceTitle": "Manage Public Resources",
|
"proxyResourceTitle": "Manage Public Resources",
|
||||||
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
||||||
|
"proxyResourcesBannerTitle": "Web-based Public Access",
|
||||||
|
"proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
||||||
"clientResourceTitle": "Manage Private Resources",
|
"clientResourceTitle": "Manage Private Resources",
|
||||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||||
|
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||||
|
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
|
||||||
"resourcesSearch": "Search resources...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
"resourceErrorDelte": "Error deleting resource",
|
"resourceErrorDelte": "Error deleting resource",
|
||||||
@@ -157,9 +165,9 @@
|
|||||||
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
||||||
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
|
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
|
||||||
"resourceHTTP": "HTTPS Resource",
|
"resourceHTTP": "HTTPS Resource",
|
||||||
"resourceHTTPDescription": "Proxy requests to the app over HTTPS using a subdomain or base domain.",
|
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||||
"resourceRaw": "Raw TCP/UDP Resource",
|
"resourceRaw": "Raw TCP/UDP Resource",
|
||||||
"resourceRawDescription": "Proxy requests to the app over TCP/UDP using a port number. This only works when sites are connected to nodes.",
|
"resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
@@ -419,7 +427,7 @@
|
|||||||
"userErrorExistsDescription": "This user is already a member of the organization.",
|
"userErrorExistsDescription": "This user is already a member of the organization.",
|
||||||
"inviteError": "Failed to invite user",
|
"inviteError": "Failed to invite user",
|
||||||
"inviteErrorDescription": "An error occurred while inviting the user",
|
"inviteErrorDescription": "An error occurred while inviting the user",
|
||||||
"userInvited": "User invited",
|
"userInvited": "User Invited",
|
||||||
"userInvitedDescription": "The user has been successfully invited.",
|
"userInvitedDescription": "The user has been successfully invited.",
|
||||||
"userErrorCreate": "Failed to create user",
|
"userErrorCreate": "Failed to create user",
|
||||||
"userErrorCreateDescription": "An error occurred while creating the user",
|
"userErrorCreateDescription": "An error occurred while creating the user",
|
||||||
@@ -687,7 +695,7 @@
|
|||||||
"resourceRoleDescription": "Admins can always access this resource.",
|
"resourceRoleDescription": "Admins can always access this resource.",
|
||||||
"resourceUsersRoles": "Access Controls",
|
"resourceUsersRoles": "Access Controls",
|
||||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||||
"resourceUsersRolesSubmit": "Save Users & Roles",
|
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||||
"resourceWhitelistSave": "Saved successfully",
|
"resourceWhitelistSave": "Saved successfully",
|
||||||
"resourceWhitelistSaveDescription": "Whitelist settings have been saved",
|
"resourceWhitelistSaveDescription": "Whitelist settings have been saved",
|
||||||
"ssoUse": "Use Platform SSO",
|
"ssoUse": "Use Platform SSO",
|
||||||
@@ -945,7 +953,7 @@
|
|||||||
"pincodeAuth": "Authenticator Code",
|
"pincodeAuth": "Authenticator Code",
|
||||||
"pincodeSubmit2": "Submit Code",
|
"pincodeSubmit2": "Submit Code",
|
||||||
"passwordResetSubmit": "Request Reset",
|
"passwordResetSubmit": "Request Reset",
|
||||||
"passwordResetAlreadyHaveCode": "Enter Password Reset Code",
|
"passwordResetAlreadyHaveCode": "Enter Code",
|
||||||
"passwordResetSmtpRequired": "Please contact your administrator",
|
"passwordResetSmtpRequired": "Please contact your administrator",
|
||||||
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
|
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
|
||||||
"passwordBack": "Back to Password",
|
"passwordBack": "Back to Password",
|
||||||
@@ -1035,6 +1043,7 @@
|
|||||||
"updateOrgUser": "Update Org User",
|
"updateOrgUser": "Update Org User",
|
||||||
"createOrgUser": "Create Org User",
|
"createOrgUser": "Create Org User",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
|
"actionRemoveInvitation": "Remove Invitation",
|
||||||
"actionUpdateUser": "Update User",
|
"actionUpdateUser": "Update User",
|
||||||
"actionGetUser": "Get User",
|
"actionGetUser": "Get User",
|
||||||
"actionGetOrgUser": "Get Organization User",
|
"actionGetOrgUser": "Get Organization User",
|
||||||
@@ -1044,6 +1053,8 @@
|
|||||||
"actionGetSite": "Get Site",
|
"actionGetSite": "Get Site",
|
||||||
"actionListSites": "List Sites",
|
"actionListSites": "List Sites",
|
||||||
"actionApplyBlueprint": "Apply Blueprint",
|
"actionApplyBlueprint": "Apply Blueprint",
|
||||||
|
"actionListBlueprints": "List Blueprints",
|
||||||
|
"actionGetBlueprint": "Get Blueprint",
|
||||||
"setupToken": "Setup Token",
|
"setupToken": "Setup Token",
|
||||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||||
"setupTokenRequired": "Setup token is required",
|
"setupTokenRequired": "Setup token is required",
|
||||||
@@ -1194,7 +1205,7 @@
|
|||||||
"sidebarUserDevices": "Users",
|
"sidebarUserDevices": "Users",
|
||||||
"sidebarMachineClients": "Machines",
|
"sidebarMachineClients": "Machines",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"sidebarGeneral": "General",
|
"sidebarGeneral": "Manage",
|
||||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||||
"sidebarBluePrints": "Blueprints",
|
"sidebarBluePrints": "Blueprints",
|
||||||
"sidebarOrganization": "Organization",
|
"sidebarOrganization": "Organization",
|
||||||
@@ -1308,8 +1319,11 @@
|
|||||||
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
|
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"saveAllSettings": "Save All Settings",
|
"saveAllSettings": "Save All Settings",
|
||||||
|
"saveResourceTargets": "Save Targets",
|
||||||
|
"saveResourceHttp": "Save Proxy Settings",
|
||||||
|
"saveProxyProtocol": "Save Proxy protocol settings",
|
||||||
"settingsUpdated": "Settings updated",
|
"settingsUpdated": "Settings updated",
|
||||||
"settingsUpdatedDescription": "All settings have been updated successfully",
|
"settingsUpdatedDescription": "Settings updated successfully",
|
||||||
"settingsErrorUpdate": "Failed to update settings",
|
"settingsErrorUpdate": "Failed to update settings",
|
||||||
"settingsErrorUpdateDescription": "An error occurred while updating settings",
|
"settingsErrorUpdateDescription": "An error occurred while updating settings",
|
||||||
"sidebarCollapse": "Collapse",
|
"sidebarCollapse": "Collapse",
|
||||||
@@ -1616,9 +1630,8 @@
|
|||||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||||
"createInternalResourceDialogName": "Name",
|
"createInternalResourceDialogName": "Name",
|
||||||
"createInternalResourceDialogSite": "Site",
|
"createInternalResourceDialogSite": "Site",
|
||||||
"createInternalResourceDialogSelectSite": "Select site...",
|
"selectSite": "Select site...",
|
||||||
"createInternalResourceDialogSearchSites": "Search sites...",
|
"noSitesFound": "No sites found.",
|
||||||
"createInternalResourceDialogNoSitesFound": "No sites found.",
|
|
||||||
"createInternalResourceDialogProtocol": "Protocol",
|
"createInternalResourceDialogProtocol": "Protocol",
|
||||||
"createInternalResourceDialogTcp": "TCP",
|
"createInternalResourceDialogTcp": "TCP",
|
||||||
"createInternalResourceDialogUdp": "UDP",
|
"createInternalResourceDialogUdp": "UDP",
|
||||||
@@ -1658,7 +1671,7 @@
|
|||||||
"siteAddressDescription": "The internal address of the site. Must fall within the organization's subnet.",
|
"siteAddressDescription": "The internal address of the site. Must fall within the organization's subnet.",
|
||||||
"siteNameDescription": "The display name of the site that can be changed later.",
|
"siteNameDescription": "The display name of the site that can be changed later.",
|
||||||
"autoLoginExternalIdp": "Auto Login with External IDP",
|
"autoLoginExternalIdp": "Auto Login with External IDP",
|
||||||
"autoLoginExternalIdpDescription": "Immediately redirect the user to the external IDP for authentication.",
|
"autoLoginExternalIdpDescription": "Immediately redirect the user to the external identity provider for authentication.",
|
||||||
"selectIdp": "Select IDP",
|
"selectIdp": "Select IDP",
|
||||||
"selectIdpPlaceholder": "Choose an IDP...",
|
"selectIdpPlaceholder": "Choose an IDP...",
|
||||||
"selectIdpRequired": "Please select an IDP when auto login is enabled.",
|
"selectIdpRequired": "Please select an IDP when auto login is enabled.",
|
||||||
@@ -1670,7 +1683,7 @@
|
|||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
"remoteExitNodeManageRemoteExitNodes": "Remote Nodes",
|
"remoteExitNodeManageRemoteExitNodes": "Remote Nodes",
|
||||||
"remoteExitNodeDescription": "Self-host one or more remote nodes to extend network connectivity and reduce reliance on the cloud",
|
"remoteExitNodeDescription": "Self-host your own remote relay and proxy server nodes",
|
||||||
"remoteExitNodes": "Nodes",
|
"remoteExitNodes": "Nodes",
|
||||||
"searchRemoteExitNodes": "Search nodes...",
|
"searchRemoteExitNodes": "Search nodes...",
|
||||||
"remoteExitNodeAdd": "Add Node",
|
"remoteExitNodeAdd": "Add Node",
|
||||||
@@ -1680,20 +1693,22 @@
|
|||||||
"remoteExitNodeConfirmDelete": "Confirm Delete Node",
|
"remoteExitNodeConfirmDelete": "Confirm Delete Node",
|
||||||
"remoteExitNodeDelete": "Delete Node",
|
"remoteExitNodeDelete": "Delete Node",
|
||||||
"sidebarRemoteExitNodes": "Remote Nodes",
|
"sidebarRemoteExitNodes": "Remote Nodes",
|
||||||
|
"remoteExitNodeId": "ID",
|
||||||
|
"remoteExitNodeSecretKey": "Secret",
|
||||||
"remoteExitNodeCreate": {
|
"remoteExitNodeCreate": {
|
||||||
"title": "Create Node",
|
"title": "Create Remote Node",
|
||||||
"description": "Create a new node to extend network connectivity",
|
"description": "Create a new self-hosted remote relay and proxy server node",
|
||||||
"viewAllButton": "View All Nodes",
|
"viewAllButton": "View All Nodes",
|
||||||
"strategy": {
|
"strategy": {
|
||||||
"title": "Creation Strategy",
|
"title": "Creation Strategy",
|
||||||
"description": "Choose this to manually configure the node or generate new credentials.",
|
"description": "Select how you want to create the remote node",
|
||||||
"adopt": {
|
"adopt": {
|
||||||
"title": "Adopt Node",
|
"title": "Adopt Node",
|
||||||
"description": "Choose this if you already have the credentials for the node."
|
"description": "Choose this if you already have the credentials for the node."
|
||||||
},
|
},
|
||||||
"generate": {
|
"generate": {
|
||||||
"title": "Generate Keys",
|
"title": "Generate Keys",
|
||||||
"description": "Choose this if you want to generate new keys for the node"
|
"description": "Choose this if you want to generate new keys for the node."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adopt": {
|
"adopt": {
|
||||||
@@ -1806,9 +1821,30 @@
|
|||||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||||
"subnet": "Subnet",
|
"subnet": "Subnet",
|
||||||
"subnetDescription": "The subnet for this organization's network configuration.",
|
"subnetDescription": "The subnet for this organization's network configuration.",
|
||||||
"authPage": "Auth Page",
|
"customDomain": "Custom Domain",
|
||||||
"authPageDescription": "Configure the auth page for the organization",
|
"authPage": "Authentication Pages",
|
||||||
|
"authPageDescription": "Set a custom domain for the organization's authentication pages",
|
||||||
"authPageDomain": "Auth Page Domain",
|
"authPageDomain": "Auth Page Domain",
|
||||||
|
"authPageBranding": "Custom Branding",
|
||||||
|
"authPageBrandingDescription": "Configure the branding that appears on authentication pages for this organization",
|
||||||
|
"authPageBrandingUpdated": "Auth page Branding updated successfully",
|
||||||
|
"authPageBrandingRemoved": "Auth page Branding removed successfully",
|
||||||
|
"authPageBrandingRemoveTitle": "Remove Auth Page Branding",
|
||||||
|
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
||||||
|
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
||||||
|
"brandingLogoURL": "Logo URL",
|
||||||
|
"brandingPrimaryColor": "Primary Color",
|
||||||
|
"brandingLogoWidth": "Width (px)",
|
||||||
|
"brandingLogoHeight": "Height (px)",
|
||||||
|
"brandingOrgTitle": "Title for Organization Auth Page",
|
||||||
|
"brandingOrgDescription": "{orgName} will be replaced with the organization's name",
|
||||||
|
"brandingOrgSubtitle": "Subtitle for Organization Auth Page",
|
||||||
|
"brandingResourceTitle": "Title for Resource Auth Page",
|
||||||
|
"brandingResourceSubtitle": "Subtitle for Resource Auth Page",
|
||||||
|
"brandingResourceDescription": "{resourceName} will be replaced with the organization's name",
|
||||||
|
"saveAuthPageDomain": "Save Domain",
|
||||||
|
"saveAuthPageBranding": "Save Branding",
|
||||||
|
"removeAuthPageBranding": "Remove Branding",
|
||||||
"noDomainSet": "No domain set",
|
"noDomainSet": "No domain set",
|
||||||
"changeDomain": "Change Domain",
|
"changeDomain": "Change Domain",
|
||||||
"selectDomain": "Select Domain",
|
"selectDomain": "Select Domain",
|
||||||
@@ -1817,7 +1853,7 @@
|
|||||||
"setAuthPageDomain": "Set Auth Page Domain",
|
"setAuthPageDomain": "Set Auth Page Domain",
|
||||||
"failedToFetchCertificate": "Failed to fetch certificate",
|
"failedToFetchCertificate": "Failed to fetch certificate",
|
||||||
"failedToRestartCertificate": "Failed to restart certificate",
|
"failedToRestartCertificate": "Failed to restart certificate",
|
||||||
"addDomainToEnableCustomAuthPages": "Add a domain to enable custom authentication pages for the organization",
|
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
|
||||||
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
||||||
"domainPickerProvidedDomain": "Provided Domain",
|
"domainPickerProvidedDomain": "Provided Domain",
|
||||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||||
@@ -1832,10 +1868,19 @@
|
|||||||
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
|
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
|
||||||
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
"domainPickerSubdomainSanitized": "Subdomain sanitized",
|
||||||
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
|
||||||
"orgAuthSignInTitle": "Sign in to the organization",
|
"orgAuthSignInTitle": "Organization Sign In",
|
||||||
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
|
"orgAuthChooseIdpDescription": "Choose your identity provider to continue",
|
||||||
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
|
"orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.",
|
||||||
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
|
"orgAuthSignInWithPangolin": "Sign in with Pangolin",
|
||||||
|
"orgAuthSignInToOrg": "Sign in to an organization",
|
||||||
|
"orgAuthSelectOrgTitle": "Organization Sign In",
|
||||||
|
"orgAuthSelectOrgDescription": "Enter your organization ID to continue",
|
||||||
|
"orgAuthOrgIdPlaceholder": "your-organization",
|
||||||
|
"orgAuthOrgIdHelp": "Enter your organization's unique identifier",
|
||||||
|
"orgAuthSelectOrgHelp": "After entering your organization ID, you'll be taken to your organization's sign-in page where you can use SSO or your organization credentials.",
|
||||||
|
"orgAuthRememberOrgId": "Remember this organization ID",
|
||||||
|
"orgAuthBackToSignIn": "Back to standard sign in",
|
||||||
|
"orgAuthNoAccount": "Don't have an account?",
|
||||||
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
|
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
|
||||||
"idpDisabled": "Identity providers are disabled.",
|
"idpDisabled": "Identity providers are disabled.",
|
||||||
"orgAuthPageDisabled": "Organization auth page is disabled.",
|
"orgAuthPageDisabled": "Organization auth page is disabled.",
|
||||||
@@ -1850,6 +1895,8 @@
|
|||||||
"enableTwoFactorAuthentication": "Enable two-factor authentication",
|
"enableTwoFactorAuthentication": "Enable two-factor authentication",
|
||||||
"completeSecuritySteps": "Complete Security Steps",
|
"completeSecuritySteps": "Complete Security Steps",
|
||||||
"securitySettings": "Security Settings",
|
"securitySettings": "Security Settings",
|
||||||
|
"dangerSection": "Danger Zone",
|
||||||
|
"dangerSectionDescription": "Permanently delete all data associated with this organization",
|
||||||
"securitySettingsDescription": "Configure security policies for the organization",
|
"securitySettingsDescription": "Configure security policies for the organization",
|
||||||
"requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users",
|
"requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users",
|
||||||
"requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.",
|
"requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.",
|
||||||
@@ -1887,7 +1934,7 @@
|
|||||||
"securityPolicyChangeWarningText": "This will affect all users in the organization",
|
"securityPolicyChangeWarningText": "This will affect all users in the organization",
|
||||||
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
||||||
"authPageErrorUpdate": "Unable to update auth page",
|
"authPageErrorUpdate": "Unable to update auth page",
|
||||||
"authPageUpdated": "Auth page updated successfully",
|
"authPageDomainUpdated": "Auth page Domain updated successfully",
|
||||||
"healthCheckNotAvailable": "Local",
|
"healthCheckNotAvailable": "Local",
|
||||||
"rewritePath": "Rewrite Path",
|
"rewritePath": "Rewrite Path",
|
||||||
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
|
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
|
||||||
@@ -1915,8 +1962,15 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"manageUserDevices": "User Devices",
|
"manageUserDevices": "User Devices",
|
||||||
"manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources",
|
"manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources",
|
||||||
|
"downloadClientBannerTitle": "Download Pangolin Client",
|
||||||
|
"downloadClientBannerDescription": "Download the Pangolin client for your system to connect to the Pangolin network and access resources privately.",
|
||||||
"manageMachineClients": "Manage Machine Clients",
|
"manageMachineClients": "Manage Machine Clients",
|
||||||
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
|
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
|
||||||
|
"machineClientsBannerTitle": "Servers & Automated Systems",
|
||||||
|
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
|
||||||
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
|
"machineClientsBannerOlmCLI": "Olm CLI",
|
||||||
|
"machineClientsBannerOlmContainer": "Olm Container",
|
||||||
"clientsTableUserClients": "User",
|
"clientsTableUserClients": "User",
|
||||||
"clientsTableMachineClients": "Machine",
|
"clientsTableMachineClients": "Machine",
|
||||||
"licenseTableValidUntil": "Valid Until",
|
"licenseTableValidUntil": "Valid Until",
|
||||||
@@ -2060,7 +2114,7 @@
|
|||||||
"request": "Request",
|
"request": "Request",
|
||||||
"requests": "Requests",
|
"requests": "Requests",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"logsSettingsDescription": "Monitor logs collected from this orginization",
|
"logsSettingsDescription": "Monitor logs collected from this organization",
|
||||||
"searchLogs": "Search logs...",
|
"searchLogs": "Search logs...",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"actor": "Actor",
|
"actor": "Actor",
|
||||||
@@ -2122,7 +2176,7 @@
|
|||||||
"unverified": "Unverified",
|
"unverified": "Unverified",
|
||||||
"domainSetting": "Domain Settings",
|
"domainSetting": "Domain Settings",
|
||||||
"domainSettingDescription": "Configure settings for the domain",
|
"domainSettingDescription": "Configure settings for the domain",
|
||||||
"preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).",
|
"preferWildcardCertDescription": "Attempt to generate a wildcard certificate (requires a properly configured certificate resolver).",
|
||||||
"recordName": "Record Name",
|
"recordName": "Record Name",
|
||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"TTL": "TTL",
|
"TTL": "TTL",
|
||||||
@@ -2257,6 +2311,8 @@
|
|||||||
"setupFailedToFetchSubnet": "Failed to fetch default subnet",
|
"setupFailedToFetchSubnet": "Failed to fetch default subnet",
|
||||||
"setupSubnetAdvanced": "Subnet (Advanced)",
|
"setupSubnetAdvanced": "Subnet (Advanced)",
|
||||||
"setupSubnetDescription": "The subnet for this organization's internal network.",
|
"setupSubnetDescription": "The subnet for this organization's internal network.",
|
||||||
|
"setupUtilitySubnet": "Utility Subnet (Advanced)",
|
||||||
|
"setupUtilitySubnetDescription": "The subnet for this organization's alias addresses and DNS server.",
|
||||||
"siteRegenerateAndDisconnect": "Regenerate and Disconnect",
|
"siteRegenerateAndDisconnect": "Regenerate and Disconnect",
|
||||||
"siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?",
|
"siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?",
|
||||||
"siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.",
|
"siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.",
|
||||||
@@ -2272,5 +2328,40 @@
|
|||||||
"remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.",
|
"remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.",
|
||||||
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
|
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
|
||||||
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.",
|
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.",
|
||||||
"agent": "Agent"
|
"agent": "Agent",
|
||||||
|
"personalUseOnly": "Personal Use Only",
|
||||||
|
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
|
||||||
|
"instanceIsUnlicensed": "This instance is unlicensed.",
|
||||||
|
"portRestrictions": "Port Restrictions",
|
||||||
|
"allPorts": "All",
|
||||||
|
"custom": "Custom",
|
||||||
|
"allPortsAllowed": "All Ports Allowed",
|
||||||
|
"allPortsBlocked": "All Ports Blocked",
|
||||||
|
"tcpPortsDescription": "Specify which TCP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 80,443,8000-9000).",
|
||||||
|
"udpPortsDescription": "Specify which UDP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 53,123,500-600).",
|
||||||
|
"organizationLoginPageTitle": "Organization Login Page",
|
||||||
|
"organizationLoginPageDescription": "Customize the login page for this organization",
|
||||||
|
"resourceLoginPageTitle": "Resource Login Page",
|
||||||
|
"resourceLoginPageDescription": "Customize the login page for individual resources",
|
||||||
|
"enterConfirmation": "Enter confirmation",
|
||||||
|
"blueprintViewDetails": "Details",
|
||||||
|
"defaultIdentityProvider": "Default Identity Provider",
|
||||||
|
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
||||||
|
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
||||||
|
"editInternalResourceDialogAddRoles": "Add Roles",
|
||||||
|
"editInternalResourceDialogAddUsers": "Add Users",
|
||||||
|
"editInternalResourceDialogAddClients": "Add Clients",
|
||||||
|
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||||
|
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.",
|
||||||
|
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
||||||
|
"editInternalResourceDialogTcp": "TCP",
|
||||||
|
"editInternalResourceDialogUdp": "UDP",
|
||||||
|
"editInternalResourceDialogIcmp": "ICMP",
|
||||||
|
"editInternalResourceDialogAccessControl": "Access Control",
|
||||||
|
"editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.",
|
||||||
|
"editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535.",
|
||||||
|
"orgAuthWhatsThis": "Where can I find my organization ID?",
|
||||||
|
"learnMore": "Learn more",
|
||||||
|
"backToHome": "Go back to home",
|
||||||
|
"needToSignInToOrg": "Need to use your organization's identity provider?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1022,6 +1022,8 @@
|
|||||||
"actionGetSite": "獲取站點",
|
"actionGetSite": "獲取站點",
|
||||||
"actionListSites": "站點列表",
|
"actionListSites": "站點列表",
|
||||||
"actionApplyBlueprint": "應用藍圖",
|
"actionApplyBlueprint": "應用藍圖",
|
||||||
|
"actionListBlueprints": "藍圖列表",
|
||||||
|
"actionGetBlueprint": "獲取藍圖",
|
||||||
"setupToken": "設置令牌",
|
"setupToken": "設置令牌",
|
||||||
"setupTokenDescription": "從伺服器控制台輸入設定令牌。",
|
"setupTokenDescription": "從伺服器控制台輸入設定令牌。",
|
||||||
"setupTokenRequired": "需要設置令牌",
|
"setupTokenRequired": "需要設置令牌",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import createNextIntlPlugin from "next-intl/plugin";
|
|||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: false,
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true
|
ignoreDuringBuilds: true
|
||||||
},
|
},
|
||||||
|
|||||||
2148
package-lock.json
generated
2148
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -19,9 +19,9 @@
|
|||||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"db:clear-migrations": "rm -rf server/migrations",
|
||||||
"set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||||
"set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||||
"set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
||||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
||||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
||||||
"next:build": "next build",
|
"next:build": "next build",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.2.0",
|
"@asteasolutions/zod-to-openapi": "8.2.0",
|
||||||
"@aws-sdk/client-s3": "3.948.0",
|
"@aws-sdk/client-s3": "3.955.0",
|
||||||
"@faker-js/faker": "10.1.0",
|
"@faker-js/faker": "10.1.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
@@ -60,12 +60,12 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.1",
|
"@react-email/components": "1.0.2",
|
||||||
"@react-email/render": "2.0.0",
|
"@react-email/render": "2.0.0",
|
||||||
"@react-email/tailwind": "2.0.1",
|
"@react-email/tailwind": "2.0.2",
|
||||||
"@simplewebauthn/browser": "13.2.2",
|
"@simplewebauthn/browser": "13.2.2",
|
||||||
"@simplewebauthn/server": "13.2.2",
|
"@simplewebauthn/server": "13.2.2",
|
||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.12",
|
"@tanstack/react-query": "5.90.12",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
@@ -82,9 +82,9 @@
|
|||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"drizzle-orm": "0.45.0",
|
"drizzle-orm": "0.45.1",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.0.8",
|
"eslint-config-next": "16.1.0",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.2.1",
|
"express-rate-limit": "8.2.1",
|
||||||
"glob": "13.0.0",
|
"glob": "13.0.0",
|
||||||
@@ -96,11 +96,11 @@
|
|||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.559.0",
|
"lucide-react": "0.562.0",
|
||||||
"maxmind": "5.0.1",
|
"maxmind": "5.0.1",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.7",
|
"next": "15.5.9",
|
||||||
"next-intl": "4.5.8",
|
"next-intl": "4.6.1",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
@@ -110,11 +110,11 @@
|
|||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.16.3",
|
"pg": "8.16.3",
|
||||||
"posthog-node": "5.17.2",
|
"posthog-node": "5.17.4",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.1",
|
"react": "19.2.3",
|
||||||
"react-day-picker": "9.12.0",
|
"react-day-picker": "9.13.0",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.3",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.68.0",
|
"react-hook-form": "7.68.0",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.0.0",
|
||||||
"resend": "6.6.0",
|
"resend": "6.6.0",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.3",
|
||||||
"stripe": "20.0.0",
|
"stripe": "20.1.0",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
@@ -136,13 +136,13 @@
|
|||||||
"ws": "8.18.3",
|
"ws": "8.18.3",
|
||||||
"yaml": "2.8.2",
|
"yaml": "2.8.2",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.1.13",
|
"zod": "4.2.1",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.51.1",
|
"@dotenvx/dotenvx": "1.51.2",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@tailwindcss/postcss": "4.1.17",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@tanstack/react-query-devtools": "5.91.1",
|
"@tanstack/react-query-devtools": "5.91.1",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
@@ -167,15 +167,15 @@
|
|||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.8",
|
"drizzle-kit": "0.31.8",
|
||||||
"esbuild": "0.27.1",
|
"esbuild": "0.27.2",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.20.1",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"react-email": "5.0.7",
|
"react-email": "5.0.7",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.18",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.49.0"
|
"typescript-eslint": "8.49.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
321
server/db/asns.ts
Normal file
321
server/db/asns.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
// Curated list of major ASNs (Cloud Providers, CDNs, ISPs, etc.)
|
||||||
|
// This is not exhaustive - there are 100,000+ ASNs globally
|
||||||
|
// Users can still enter any ASN manually in the input field
|
||||||
|
export const MAJOR_ASNS = [
|
||||||
|
{
|
||||||
|
name: "ALL ASNs",
|
||||||
|
code: "ALL",
|
||||||
|
asn: 0 // Special value that will match all
|
||||||
|
},
|
||||||
|
// Major Cloud Providers
|
||||||
|
{
|
||||||
|
name: "Google LLC",
|
||||||
|
code: "AS15169",
|
||||||
|
asn: 15169
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amazon AWS",
|
||||||
|
code: "AS16509",
|
||||||
|
asn: 16509
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amazon AWS (EC2)",
|
||||||
|
code: "AS14618",
|
||||||
|
asn: 14618
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Microsoft Azure",
|
||||||
|
code: "AS8075",
|
||||||
|
asn: 8075
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Microsoft Corporation",
|
||||||
|
code: "AS8068",
|
||||||
|
asn: 8068
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DigitalOcean",
|
||||||
|
code: "AS14061",
|
||||||
|
asn: 14061
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Linode",
|
||||||
|
code: "AS63949",
|
||||||
|
asn: 63949
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hetzner Online",
|
||||||
|
code: "AS24940",
|
||||||
|
asn: 24940
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OVH SAS",
|
||||||
|
code: "AS16276",
|
||||||
|
asn: 16276
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Oracle Cloud",
|
||||||
|
code: "AS31898",
|
||||||
|
asn: 31898
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Alibaba Cloud",
|
||||||
|
code: "AS45102",
|
||||||
|
asn: 45102
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IBM Cloud",
|
||||||
|
code: "AS36351",
|
||||||
|
asn: 36351
|
||||||
|
},
|
||||||
|
|
||||||
|
// CDNs
|
||||||
|
{
|
||||||
|
name: "Cloudflare",
|
||||||
|
code: "AS13335",
|
||||||
|
asn: 13335
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fastly",
|
||||||
|
code: "AS54113",
|
||||||
|
asn: 54113
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Akamai Technologies",
|
||||||
|
code: "AS20940",
|
||||||
|
asn: 20940
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Akamai (Primary)",
|
||||||
|
code: "AS16625",
|
||||||
|
asn: 16625
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mobile Carriers - US
|
||||||
|
{
|
||||||
|
name: "T-Mobile USA",
|
||||||
|
code: "AS21928",
|
||||||
|
asn: 21928
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verizon Wireless",
|
||||||
|
code: "AS6167",
|
||||||
|
asn: 6167
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AT&T Mobility",
|
||||||
|
code: "AS20057",
|
||||||
|
asn: 20057
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sprint (T-Mobile)",
|
||||||
|
code: "AS1239",
|
||||||
|
asn: 1239
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "US Cellular",
|
||||||
|
code: "AS6430",
|
||||||
|
asn: 6430
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mobile Carriers - Europe
|
||||||
|
{
|
||||||
|
name: "Vodafone UK",
|
||||||
|
code: "AS25135",
|
||||||
|
asn: 25135
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EE (UK)",
|
||||||
|
code: "AS12576",
|
||||||
|
asn: 12576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Three UK",
|
||||||
|
code: "AS29194",
|
||||||
|
asn: 29194
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "O2 UK",
|
||||||
|
code: "AS13285",
|
||||||
|
asn: 13285
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Telefonica Spain Mobile",
|
||||||
|
code: "AS12430",
|
||||||
|
asn: 12430
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mobile Carriers - Asia
|
||||||
|
{
|
||||||
|
name: "NTT DoCoMo (Japan)",
|
||||||
|
code: "AS9605",
|
||||||
|
asn: 9605
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SoftBank Mobile (Japan)",
|
||||||
|
code: "AS17676",
|
||||||
|
asn: 17676
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SK Telecom (Korea)",
|
||||||
|
code: "AS9318",
|
||||||
|
asn: 9318
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "KT Corporation Mobile (Korea)",
|
||||||
|
code: "AS4766",
|
||||||
|
asn: 4766
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Airtel India",
|
||||||
|
code: "AS24560",
|
||||||
|
asn: 24560
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "China Mobile",
|
||||||
|
code: "AS9808",
|
||||||
|
asn: 9808
|
||||||
|
},
|
||||||
|
|
||||||
|
// Major US ISPs
|
||||||
|
{
|
||||||
|
name: "AT&T Services",
|
||||||
|
code: "AS7018",
|
||||||
|
asn: 7018
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Comcast Cable",
|
||||||
|
code: "AS7922",
|
||||||
|
asn: 7922
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Verizon",
|
||||||
|
code: "AS701",
|
||||||
|
asn: 701
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cox Communications",
|
||||||
|
code: "AS22773",
|
||||||
|
asn: 22773
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Charter Communications",
|
||||||
|
code: "AS20115",
|
||||||
|
asn: 20115
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CenturyLink",
|
||||||
|
code: "AS209",
|
||||||
|
asn: 209
|
||||||
|
},
|
||||||
|
|
||||||
|
// Major European ISPs
|
||||||
|
{
|
||||||
|
name: "Deutsche Telekom",
|
||||||
|
code: "AS3320",
|
||||||
|
asn: 3320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vodafone",
|
||||||
|
code: "AS1273",
|
||||||
|
asn: 1273
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "British Telecom",
|
||||||
|
code: "AS2856",
|
||||||
|
asn: 2856
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Orange",
|
||||||
|
code: "AS3215",
|
||||||
|
asn: 3215
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Telefonica",
|
||||||
|
code: "AS12956",
|
||||||
|
asn: 12956
|
||||||
|
},
|
||||||
|
|
||||||
|
// Major Asian ISPs
|
||||||
|
{
|
||||||
|
name: "China Telecom",
|
||||||
|
code: "AS4134",
|
||||||
|
asn: 4134
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "China Unicom",
|
||||||
|
code: "AS4837",
|
||||||
|
asn: 4837
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NTT Communications",
|
||||||
|
code: "AS2914",
|
||||||
|
asn: 2914
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "KDDI Corporation",
|
||||||
|
code: "AS2516",
|
||||||
|
asn: 2516
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reliance Jio (India)",
|
||||||
|
code: "AS55836",
|
||||||
|
asn: 55836
|
||||||
|
},
|
||||||
|
|
||||||
|
// VPN/Proxy Providers
|
||||||
|
{
|
||||||
|
name: "Private Internet Access",
|
||||||
|
code: "AS46562",
|
||||||
|
asn: 46562
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NordVPN",
|
||||||
|
code: "AS202425",
|
||||||
|
asn: 202425
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mullvad VPN",
|
||||||
|
code: "AS213281",
|
||||||
|
asn: 213281
|
||||||
|
},
|
||||||
|
|
||||||
|
// Social Media / Major Tech
|
||||||
|
{
|
||||||
|
name: "Facebook/Meta",
|
||||||
|
code: "AS32934",
|
||||||
|
asn: 32934
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Twitter/X",
|
||||||
|
code: "AS13414",
|
||||||
|
asn: 13414
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Apple",
|
||||||
|
code: "AS714",
|
||||||
|
asn: 714
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Netflix",
|
||||||
|
code: "AS2906",
|
||||||
|
asn: 2906
|
||||||
|
},
|
||||||
|
|
||||||
|
// Academic/Research
|
||||||
|
{
|
||||||
|
name: "MIT",
|
||||||
|
code: "AS3",
|
||||||
|
asn: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Stanford University",
|
||||||
|
code: "AS32",
|
||||||
|
asn: 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CERN",
|
||||||
|
code: "AS513",
|
||||||
|
asn: 513
|
||||||
|
}
|
||||||
|
];
|
||||||
13
server/db/maxmindAsn.ts
Normal file
13
server/db/maxmindAsn.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import maxmind, { AsnResponse, Reader } from "maxmind";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
let maxmindAsnLookup: Reader<AsnResponse> | null;
|
||||||
|
if (config.getRawConfig().server.maxmind_asn_path) {
|
||||||
|
maxmindAsnLookup = await maxmind.open<AsnResponse>(
|
||||||
|
config.getRawConfig().server.maxmind_asn_path!
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
maxmindAsnLookup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { maxmindAsnLookup };
|
||||||
@@ -6,28 +6,28 @@ import { withReplicas } from "drizzle-orm/pg-core";
|
|||||||
function createDb() {
|
function createDb() {
|
||||||
const config = readConfigFile();
|
const config = readConfigFile();
|
||||||
|
|
||||||
if (!config.postgres) {
|
// check the environment variables for postgres config first before the config file
|
||||||
// check the environment variables for postgres config
|
if (process.env.POSTGRES_CONNECTION_STRING) {
|
||||||
if (process.env.POSTGRES_CONNECTION_STRING) {
|
config.postgres = {
|
||||||
config.postgres = {
|
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
||||||
connection_string: process.env.POSTGRES_CONNECTION_STRING
|
};
|
||||||
};
|
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
||||||
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
|
const replicas =
|
||||||
const replicas =
|
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map(
|
||||||
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(
|
(conn) => ({
|
||||||
","
|
|
||||||
).map((conn) => ({
|
|
||||||
connection_string: conn.trim()
|
connection_string: conn.trim()
|
||||||
}));
|
})
|
||||||
config.postgres.replicas = replicas;
|
);
|
||||||
}
|
config.postgres.replicas = replicas;
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Postgres configuration is missing in the configuration file."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config.postgres) {
|
||||||
|
throw new Error(
|
||||||
|
"Postgres configuration is missing in the configuration file."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const connectionString = config.postgres?.connection_string;
|
const connectionString = config.postgres?.connection_string;
|
||||||
const replicaConnections = config.postgres?.replicas || [];
|
const replicaConnections = config.postgres?.replicas || [];
|
||||||
|
|
||||||
@@ -81,6 +81,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
|
export const primaryDb = db.$primary;
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const runMigrations = async () => {
|
|||||||
await migrate(db as any, {
|
await migrate(db as any, {
|
||||||
migrationsFolder: migrationsFolder
|
migrationsFolder: migrationsFolder
|
||||||
});
|
});
|
||||||
console.log("Migrations completed successfully.");
|
console.log("Migrations completed successfully. ✅");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error running migrations:", error);
|
console.error("Error running migrations:", error);
|
||||||
|
|||||||
@@ -204,6 +204,29 @@ export const loginPageOrg = pgTable("loginPageOrg", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const loginPageBranding = pgTable("loginPageBranding", {
|
||||||
|
loginPageBrandingId: serial("loginPageBrandingId").primaryKey(),
|
||||||
|
logoUrl: text("logoUrl").notNull(),
|
||||||
|
logoWidth: integer("logoWidth").notNull(),
|
||||||
|
logoHeight: integer("logoHeight").notNull(),
|
||||||
|
primaryColor: text("primaryColor"),
|
||||||
|
resourceTitle: text("resourceTitle").notNull(),
|
||||||
|
resourceSubtitle: text("resourceSubtitle"),
|
||||||
|
orgTitle: text("orgTitle"),
|
||||||
|
orgSubtitle: text("orgSubtitle")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginPageBrandingOrg = pgTable("loginPageBrandingOrg", {
|
||||||
|
loginPageBrandingId: integer("loginPageBrandingId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => loginPageBranding.loginPageBrandingId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
orgId: varchar("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
|
});
|
||||||
|
|
||||||
export const sessionTransferToken = pgTable("sessionTransferToken", {
|
export const sessionTransferToken = pgTable("sessionTransferToken", {
|
||||||
token: varchar("token").primaryKey(),
|
token: varchar("token").primaryKey(),
|
||||||
sessionId: varchar("sessionId")
|
sessionId: varchar("sessionId")
|
||||||
@@ -283,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||||
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
bigint,
|
bigint,
|
||||||
real,
|
real,
|
||||||
text,
|
text,
|
||||||
index
|
index,
|
||||||
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
@@ -213,7 +214,10 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
alias: varchar("alias"),
|
alias: varchar("alias"),
|
||||||
aliasAddress: varchar("aliasAddress")
|
aliasAddress: varchar("aliasAddress"),
|
||||||
|
tcpPortRangeString: varchar("tcpPortRangeString"),
|
||||||
|
udpPortRangeString: varchar("udpPortRangeString"),
|
||||||
|
disableIcmp: boolean("disableIcmp").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clientSiteResources = pgTable("clientSiteResources", {
|
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
|
export const primaryDb = db;
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import {
|
|
||||||
sqliteTable,
|
|
||||||
integer,
|
|
||||||
text,
|
|
||||||
real,
|
|
||||||
index
|
|
||||||
} from "drizzle-orm/sqlite-core";
|
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
import {
|
||||||
import { metadata } from "@app/app/[orgId]/settings/layout";
|
index,
|
||||||
|
integer,
|
||||||
|
real,
|
||||||
|
sqliteTable,
|
||||||
|
text
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { domains, exitNodes, orgs, sessions, users } from "./schema";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -203,6 +202,31 @@ export const loginPageOrg = sqliteTable("loginPageOrg", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const loginPageBranding = sqliteTable("loginPageBranding", {
|
||||||
|
loginPageBrandingId: integer("loginPageBrandingId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
logoUrl: text("logoUrl").notNull(),
|
||||||
|
logoWidth: integer("logoWidth").notNull(),
|
||||||
|
logoHeight: integer("logoHeight").notNull(),
|
||||||
|
primaryColor: text("primaryColor"),
|
||||||
|
resourceTitle: text("resourceTitle").notNull(),
|
||||||
|
resourceSubtitle: text("resourceSubtitle"),
|
||||||
|
orgTitle: text("orgTitle"),
|
||||||
|
orgSubtitle: text("orgSubtitle")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginPageBrandingOrg = sqliteTable("loginPageBrandingOrg", {
|
||||||
|
loginPageBrandingId: integer("loginPageBrandingId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => loginPageBranding.loginPageBrandingId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
|
});
|
||||||
|
|
||||||
export const sessionTransferToken = sqliteTable("sessionTransferToken", {
|
export const sessionTransferToken = sqliteTable("sessionTransferToken", {
|
||||||
token: text("token").primaryKey(),
|
token: text("token").primaryKey(),
|
||||||
sessionId: text("sessionId")
|
sessionId: text("sessionId")
|
||||||
@@ -282,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||||
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
import {
|
||||||
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
index,
|
||||||
|
uniqueIndex
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { no } from "zod/v4/locales";
|
import { no } from "zod/v4/locales";
|
||||||
|
|
||||||
export const domains = sqliteTable("domains", {
|
export const domains = sqliteTable("domains", {
|
||||||
@@ -234,7 +240,10 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
alias: text("alias"),
|
alias: text("alias"),
|
||||||
aliasAddress: text("aliasAddress")
|
aliasAddress: text("aliasAddress"),
|
||||||
|
tcpPortRangeString: text("tcpPortRangeString"),
|
||||||
|
udpPortRangeString: text("udpPortRangeString"),
|
||||||
|
disableIcmp: integer("disableIcmp", { mode: "boolean" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function sendEmail(
|
|||||||
from: string | undefined;
|
from: string | undefined;
|
||||||
to: string | undefined;
|
to: string | undefined;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
replyTo?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (!emailClient) {
|
if (!emailClient) {
|
||||||
@@ -32,6 +33,7 @@ export async function sendEmail(
|
|||||||
address: opts.from
|
address: opts.from
|
||||||
},
|
},
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
|
replyTo: opts.replyTo,
|
||||||
subject: opts.subject,
|
subject: opts.subject,
|
||||||
html: emailHtml
|
html: emailHtml
|
||||||
});
|
});
|
||||||
|
|||||||
29
server/lib/asn.ts
Normal file
29
server/lib/asn.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
|
||||||
|
|
||||||
|
export async function getAsnForIp(ip: string): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
if (!maxmindAsnLookup) {
|
||||||
|
logger.debug(
|
||||||
|
"MaxMind ASN DB path not configured, cannot perform ASN lookup"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = maxmindAsnLookup.get(ip);
|
||||||
|
|
||||||
|
if (!result || !result.autonomous_system_number) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`ASN lookup successful for IP ${ip}: AS${result.autonomous_system_number}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.autonomous_system_number;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error performing ASN lookup:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, newts, blueprints, Blueprint } from "@server/db";
|
import { db, newts, blueprints, Blueprint, Site, siteResources, roleSiteResources, userSiteResources, clientSiteResources } from "@server/db";
|
||||||
import { Config, ConfigSchema } from "./types";
|
import { Config, ConfigSchema } from "./types";
|
||||||
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
@@ -15,6 +15,7 @@ import { BlueprintSource } from "@server/routers/blueprints/types";
|
|||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
||||||
|
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -108,38 +109,136 @@ export async function applyBlueprint({
|
|||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of clientResourcesResults) {
|
for (const result of clientResourcesResults) {
|
||||||
const [site] = await trx
|
if (
|
||||||
.select()
|
result.oldSiteResource &&
|
||||||
.from(sites)
|
result.oldSiteResource.siteId !=
|
||||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
result.newSiteResource.siteId
|
||||||
.where(
|
) {
|
||||||
and(
|
// query existing associations
|
||||||
eq(sites.siteId, result.newSiteResource.siteId),
|
const existingRoleIds = await trx
|
||||||
eq(sites.orgId, orgId),
|
.select()
|
||||||
eq(sites.type, "newt"),
|
.from(roleSiteResources)
|
||||||
isNotNull(sites.pubKey)
|
.where(
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
const existingUserIds= await trx
|
||||||
logger.debug(
|
.select()
|
||||||
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
.from(userSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
).then((rows) => rows.map((row) => row.userId));
|
||||||
|
|
||||||
|
const existingClientIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
).then((rows) => rows.map((row) => row.clientId));
|
||||||
|
|
||||||
|
// delete the existing site resource
|
||||||
|
await trx
|
||||||
|
.delete(siteResources)
|
||||||
|
.where(
|
||||||
|
and(eq(siteResources.siteResourceId, result.oldSiteResource.siteResourceId))
|
||||||
|
);
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const [insertedSiteResource] = await trx
|
||||||
|
.insert(siteResources)
|
||||||
|
.values({
|
||||||
|
...result.newSiteResource,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// wait some time to allow for messages to be handled
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
if (existingRoleIds.length > 0) {
|
||||||
|
await trx.insert(roleSiteResources).values(
|
||||||
|
existingRoleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId: insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUserIds.length > 0) {
|
||||||
|
await trx.insert(userSiteResources).values(
|
||||||
|
existingUserIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId: insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingClientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
existingClientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId: insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
insertedSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const [newSite] = await trx
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.siteId, result.newSiteResource.siteId),
|
||||||
|
eq(sites.orgId, orgId),
|
||||||
|
eq(sites.type, "newt"),
|
||||||
|
isNotNull(sites.pubKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newSite) {
|
||||||
|
logger.debug(
|
||||||
|
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleMessagingForUpdatedSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
result.newSiteResource,
|
||||||
|
{
|
||||||
|
siteId: newSite.sites.siteId,
|
||||||
|
orgId: newSite.sites.orgId
|
||||||
|
},
|
||||||
|
trx
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${site.sites.siteId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await handleMessagingForUpdatedSiteResource(
|
|
||||||
result.oldSiteResource,
|
|
||||||
result.newSiteResource,
|
|
||||||
{ siteId: site.sites.siteId, orgId: site.sites.orgId },
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// await addClientTargets(
|
// await addClientTargets(
|
||||||
// site.newt.newtId,
|
// site.newt.newtId,
|
||||||
// result.resource.destination,
|
// result.resource.destination,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { sites } from "@server/db";
|
|||||||
import { eq, and, ne, inArray } from "drizzle-orm";
|
import { eq, and, ne, inArray } from "drizzle-orm";
|
||||||
import { Config } from "./types";
|
import { Config } from "./types";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
|
|
||||||
export type ClientResourcesResults = {
|
export type ClientResourcesResults = {
|
||||||
newSiteResource: SiteResource;
|
newSiteResource: SiteResource;
|
||||||
@@ -75,22 +76,20 @@ export async function updateClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
if (existingResource.siteId !== site.siteId) {
|
|
||||||
throw new Error(
|
|
||||||
`You can not change the site of an existing client resource (${resourceNiceId}). Please delete and recreate it instead.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
const [updatedResource] = await trx
|
const [updatedResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
.set({
|
.set({
|
||||||
name: resourceData.name || resourceNiceId,
|
name: resourceData.name || resourceNiceId,
|
||||||
|
siteId: site.siteId,
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
enabled: true, // hardcoded for now
|
enabled: true, // hardcoded for now
|
||||||
// enabled: resourceData.enabled ?? true,
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null
|
alias: resourceData.alias || null,
|
||||||
|
disableIcmp: resourceData["disable-icmp"],
|
||||||
|
tcpPortRangeString: resourceData["tcp-ports"],
|
||||||
|
udpPortRangeString: resourceData["udp-ports"]
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -205,6 +204,12 @@ export async function updateClientResources(
|
|||||||
oldSiteResource: existingResource
|
oldSiteResource: existingResource
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
let aliasAddress: string | null = null;
|
||||||
|
if (resourceData.mode == "host") {
|
||||||
|
// we can only have an alias on a host
|
||||||
|
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
// Create new resource
|
// Create new resource
|
||||||
const [newResource] = await trx
|
const [newResource] = await trx
|
||||||
.insert(siteResources)
|
.insert(siteResources)
|
||||||
@@ -217,7 +222,11 @@ export async function updateClientResources(
|
|||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
enabled: true, // hardcoded for now
|
enabled: true, // hardcoded for now
|
||||||
// enabled: resourceData.enabled ?? true,
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null
|
alias: resourceData.alias || null,
|
||||||
|
aliasAddress: aliasAddress,
|
||||||
|
disableIcmp: resourceData["disable-icmp"],
|
||||||
|
tcpPortRangeString: resourceData["tcp-ports"],
|
||||||
|
udpPortRangeString: resourceData["udp-ports"]
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
|
|
||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -71,11 +72,71 @@ export const AuthSchema = z.object({
|
|||||||
"auto-login-idp": z.int().positive().optional()
|
"auto-login-idp": z.int().positive().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RuleSchema = z.object({
|
export const RuleSchema = z
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
.object({
|
||||||
match: z.enum(["cidr", "path", "ip", "country"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
value: z.string()
|
match: z.enum(["cidr", "path", "ip", "country", "asn"]),
|
||||||
});
|
value: z.string()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(rule) => {
|
||||||
|
if (rule.match === "ip") {
|
||||||
|
// Check if it's a valid IP address (v4 or v6)
|
||||||
|
return z.union([z.ipv4(), z.ipv6()]).safeParse(rule.value)
|
||||||
|
.success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["value"],
|
||||||
|
message: "Value must be a valid IP address when match is 'ip'"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(rule) => {
|
||||||
|
if (rule.match === "cidr") {
|
||||||
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
|
return z.union([z.cidrv4(), z.cidrv6()]).safeParse(rule.value)
|
||||||
|
.success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["value"],
|
||||||
|
message: "Value must be a valid CIDR notation when match is 'cidr'"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(rule) => {
|
||||||
|
if (rule.match === "country") {
|
||||||
|
// Check if it's a valid 2-letter country code
|
||||||
|
return /^[A-Z]{2}$/.test(rule.value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["value"],
|
||||||
|
message:
|
||||||
|
"Value must be a 2-letter country code when match is 'country'"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(rule) => {
|
||||||
|
if (rule.match === "asn") {
|
||||||
|
// Check if it's either AS<number> format or just a number
|
||||||
|
const asNumberPattern = /^AS\d+$/i;
|
||||||
|
const isASFormat = asNumberPattern.test(rule.value);
|
||||||
|
const isNumeric = /^\d+$/.test(rule.value);
|
||||||
|
return isASFormat || isNumeric;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["value"],
|
||||||
|
message:
|
||||||
|
"Value must be either 'AS<number>' format or a number when match is 'asn'"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const HeaderSchema = z.object({
|
export const HeaderSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -222,6 +283,9 @@ export const ClientResourceSchema = z
|
|||||||
// destinationPort: z.int().positive().optional(),
|
// destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
// enabled: z.boolean().default(true),
|
// enabled: z.boolean().default(true),
|
||||||
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
|
"disable-icmp": z.boolean().optional().default(false),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export class Config {
|
|||||||
process.env.MAXMIND_DB_PATH = parsedConfig.server.maxmind_db_path;
|
process.env.MAXMIND_DB_PATH = parsedConfig.server.maxmind_db_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedConfig.server.maxmind_asn_path) {
|
||||||
|
process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path;
|
||||||
|
}
|
||||||
|
|
||||||
this.rawConfig = parsedConfig;
|
this.rawConfig = parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.13.0";
|
export const APP_VERSION = "1.13.1";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
184
server/lib/ip.ts
184
server/lib/ip.ts
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { db, SiteResource, siteResources, Transaction } from "@server/db";
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
SiteResource,
|
|
||||||
siteResources,
|
|
||||||
Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import { clients, orgs, sites } from "@server/db";
|
import { clients, orgs, sites } from "@server/db";
|
||||||
import { and, eq, isNotNull } from "drizzle-orm";
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -120,11 +114,13 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
|||||||
* Parses an endpoint string (ip:port) handling both IPv4 and IPv6 addresses.
|
* Parses an endpoint string (ip:port) handling both IPv4 and IPv6 addresses.
|
||||||
* IPv6 addresses may be bracketed like [::1]:8080 or unbracketed like ::1:8080.
|
* IPv6 addresses may be bracketed like [::1]:8080 or unbracketed like ::1:8080.
|
||||||
* For unbracketed IPv6, the last colon-separated segment is treated as the port.
|
* For unbracketed IPv6, the last colon-separated segment is treated as the port.
|
||||||
*
|
*
|
||||||
* @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080")
|
* @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080")
|
||||||
* @returns An object with ip and port, or null if parsing fails
|
* @returns An object with ip and port, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
export function parseEndpoint(endpoint: string): { ip: string; port: number } | null {
|
export function parseEndpoint(
|
||||||
|
endpoint: string
|
||||||
|
): { ip: string; port: number } | null {
|
||||||
if (!endpoint) return null;
|
if (!endpoint) return null;
|
||||||
|
|
||||||
// Check for bracketed IPv6 format: [ip]:port
|
// Check for bracketed IPv6 format: [ip]:port
|
||||||
@@ -138,7 +134,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } |
|
|||||||
|
|
||||||
// Check if this looks like IPv6 (contains multiple colons)
|
// Check if this looks like IPv6 (contains multiple colons)
|
||||||
const colonCount = (endpoint.match(/:/g) || []).length;
|
const colonCount = (endpoint.match(/:/g) || []).length;
|
||||||
|
|
||||||
if (colonCount > 1) {
|
if (colonCount > 1) {
|
||||||
// This is IPv6 - the port is after the last colon
|
// This is IPv6 - the port is after the last colon
|
||||||
const lastColonIndex = endpoint.lastIndexOf(":");
|
const lastColonIndex = endpoint.lastIndexOf(":");
|
||||||
@@ -163,7 +159,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } |
|
|||||||
/**
|
/**
|
||||||
* Formats an IP and port into a consistent endpoint string.
|
* Formats an IP and port into a consistent endpoint string.
|
||||||
* IPv6 addresses are wrapped in brackets for proper parsing.
|
* IPv6 addresses are wrapped in brackets for proper parsing.
|
||||||
*
|
*
|
||||||
* @param ip The IP address (IPv4 or IPv6)
|
* @param ip The IP address (IPv4 or IPv6)
|
||||||
* @param port The port number
|
* @param port The port number
|
||||||
* @returns Formatted endpoint string
|
* @returns Formatted endpoint string
|
||||||
@@ -305,6 +301,29 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
|
|||||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if two CIDR ranges overlap
|
||||||
|
* @param cidr1 First CIDR string
|
||||||
|
* @param cidr2 Second CIDR string
|
||||||
|
* @returns boolean indicating if the two CIDRs overlap
|
||||||
|
*/
|
||||||
|
export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
|
||||||
|
const version1 = detectIpVersion(cidr1.split("/")[0]);
|
||||||
|
const version2 = detectIpVersion(cidr2.split("/")[0]);
|
||||||
|
if (version1 !== version2) {
|
||||||
|
// Different IP versions cannot overlap
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const range1 = cidrToRange(cidr1);
|
||||||
|
const range2 = cidrToRange(cidr2);
|
||||||
|
|
||||||
|
// Overlap if the ranges intersect
|
||||||
|
return (
|
||||||
|
range1.start <= range2.end &&
|
||||||
|
range2.start <= range1.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNextAvailableClientSubnet(
|
export async function getNextAvailableClientSubnet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
transaction: Transaction | typeof db = db
|
transaction: Transaction | typeof db = db
|
||||||
@@ -430,7 +449,12 @@ export function generateRemoteSubnets(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const remoteSubnets = allSiteResources
|
const remoteSubnets = allSiteResources
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
if (sr.mode === "cidr") return true;
|
if (sr.mode === "cidr") {
|
||||||
|
// check if its a valid CIDR using zod
|
||||||
|
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
||||||
|
const parseResult = cidrSchema.safeParse(sr.destination);
|
||||||
|
return parseResult.success;
|
||||||
|
}
|
||||||
if (sr.mode === "host") {
|
if (sr.mode === "host") {
|
||||||
// check if its a valid IP using zod
|
// check if its a valid IP using zod
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
@@ -454,22 +478,23 @@ export function generateRemoteSubnets(
|
|||||||
export type Alias = { alias: string | null; aliasAddress: string | null };
|
export type Alias = { alias: string | null; aliasAddress: string | null };
|
||||||
|
|
||||||
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||||
let aliasConfigs = allSiteResources
|
return allSiteResources
|
||||||
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
|
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
alias: sr.alias,
|
alias: sr.alias,
|
||||||
aliasAddress: sr.aliasAddress
|
aliasAddress: sr.aliasAddress
|
||||||
}));
|
}));
|
||||||
return aliasConfigs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SubnetProxyTarget = {
|
export type SubnetProxyTarget = {
|
||||||
sourcePrefix: string; // must be a cidr
|
sourcePrefix: string; // must be a cidr
|
||||||
destPrefix: string; // must be a cidr
|
destPrefix: string; // must be a cidr
|
||||||
|
disableIcmp?: boolean;
|
||||||
rewriteTo?: string; // must be a cidr
|
rewriteTo?: string; // must be a cidr
|
||||||
portRange?: {
|
portRange?: {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
protocol: "tcp" | "udp";
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -499,6 +524,11 @@ export function generateSubnetProxyTargets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||||
|
const portRange = [
|
||||||
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
|
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
|
||||||
|
];
|
||||||
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host") {
|
if (siteResource.mode == "host") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
@@ -509,7 +539,9 @@ export function generateSubnetProxyTargets(
|
|||||||
|
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: destination
|
destPrefix: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,13 +550,17 @@ export function generateSubnetProxyTargets(
|
|||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination
|
rewriteTo: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: siteResource.destination
|
destPrefix: siteResource.destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,3 +572,117 @@ export function generateSubnetProxyTargets(
|
|||||||
|
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom schema for validating port range strings
|
||||||
|
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
||||||
|
export const portRangeStringSchema = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by comma and validate each part
|
||||||
|
const parts = val.split(",").map((p) => p.trim());
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === "") {
|
||||||
|
return false; // empty parts not allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a range (contains dash)
|
||||||
|
if (part.includes("-")) {
|
||||||
|
const [start, end] = part.split("-").map((p) => p.trim());
|
||||||
|
|
||||||
|
// Both parts must be present
|
||||||
|
if (!start || !end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPort = parseInt(start, 10);
|
||||||
|
const endPort = parseInt(end, 10);
|
||||||
|
|
||||||
|
// Must be valid numbers
|
||||||
|
if (isNaN(startPort) || isNaN(endPort)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be valid port range (1-65535)
|
||||||
|
if (
|
||||||
|
startPort < 1 ||
|
||||||
|
startPort > 65535 ||
|
||||||
|
endPort < 1 ||
|
||||||
|
endPort > 65535
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start must be <= end
|
||||||
|
if (startPort > endPort) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single port
|
||||||
|
const port = parseInt(part, 10);
|
||||||
|
|
||||||
|
// Must be a valid number
|
||||||
|
if (isNaN(port)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be valid port range (1-65535)
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a port range string into an array of port range objects
|
||||||
|
* @param portRangeStr - Port range string (e.g., "80,443,8000-9000", "*", or "")
|
||||||
|
* @param protocol - Protocol to use for all ranges (default: "tcp")
|
||||||
|
* @returns Array of port range objects with min, max, and protocol fields
|
||||||
|
*/
|
||||||
|
export function parsePortRangeString(
|
||||||
|
portRangeStr: string | undefined | null,
|
||||||
|
protocol: "tcp" | "udp" = "tcp"
|
||||||
|
): { min: number; max: number; protocol: "tcp" | "udp" }[] {
|
||||||
|
// Handle undefined or empty string - insert dummy value with port 0
|
||||||
|
if (!portRangeStr || portRangeStr.trim() === "") {
|
||||||
|
return [{ min: 0, max: 0, protocol }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard - return empty array (all ports allowed)
|
||||||
|
if (portRangeStr.trim() === "*") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: { min: number; max: number; protocol: "tcp" | "udp" }[] = [];
|
||||||
|
const parts = portRangeStr.split(",").map((p) => p.trim());
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes("-")) {
|
||||||
|
// Range
|
||||||
|
const [start, end] = part.split("-").map((p) => p.trim());
|
||||||
|
const startPort = parseInt(start, 10);
|
||||||
|
const endPort = parseInt(end, 10);
|
||||||
|
result.push({ min: startPort, max: endPort, protocol });
|
||||||
|
} else {
|
||||||
|
// Single port
|
||||||
|
const port = parseInt(part, 10);
|
||||||
|
result.push({ min: port, max: port, protocol });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ export const configSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
trust_proxy: z.int().gte(0).optional().default(1),
|
trust_proxy: z.int().gte(0).optional().default(1),
|
||||||
secret: z.string().pipe(z.string().min(8)).optional(),
|
secret: z.string().pipe(z.string().min(8)).optional(),
|
||||||
maxmind_db_path: z.string().optional()
|
maxmind_db_path: z.string().optional(),
|
||||||
|
maxmind_asn_path: z.string().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default({
|
.default({
|
||||||
@@ -255,11 +256,11 @@ export const configSchema = z
|
|||||||
orgs: z
|
orgs: z
|
||||||
.object({
|
.object({
|
||||||
block_size: z.number().positive().gt(0).optional().default(24),
|
block_size: z.number().positive().gt(0).optional().default(24),
|
||||||
subnet_group: z.string().optional().default("100.90.128.0/24"),
|
subnet_group: z.string().optional().default("100.90.128.0/20"),
|
||||||
utility_subnet_group: z
|
utility_subnet_group: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.default("100.96.128.0/24") //just hardcode this for now as well
|
.default("100.96.128.0/20") //just hardcode this for now as well
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default({
|
.default({
|
||||||
|
|||||||
@@ -955,28 +955,8 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
|
|
||||||
/////////// Send messages ///////////
|
/////////// Send messages ///////////
|
||||||
|
|
||||||
// Get the olm for this client
|
|
||||||
const [olm] = await trx
|
|
||||||
.select({ olmId: olms.olmId })
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.clientId, client.clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!olm) {
|
|
||||||
logger.warn(
|
|
||||||
`Olm not found for client ${client.clientId}, skipping peer updates`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle messages for sites being added
|
// Handle messages for sites being added
|
||||||
await handleMessagesForClientSites(
|
await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx);
|
||||||
client,
|
|
||||||
olm.olmId,
|
|
||||||
sitesToAdd,
|
|
||||||
sitesToRemove,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle subnet proxy target updates for resources
|
// Handle subnet proxy target updates for resources
|
||||||
await handleMessagesForClientResources(
|
await handleMessagesForClientResources(
|
||||||
@@ -996,11 +976,26 @@ async function handleMessagesForClientSites(
|
|||||||
userId: string | null;
|
userId: string | null;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
},
|
},
|
||||||
olmId: string,
|
|
||||||
sitesToAdd: number[],
|
sitesToAdd: number[],
|
||||||
sitesToRemove: number[],
|
sitesToRemove: number[],
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Get the olm for this client
|
||||||
|
const [olm] = await trx
|
||||||
|
.select({ olmId: olms.olmId })
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.clientId, client.clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!olm) {
|
||||||
|
logger.warn(
|
||||||
|
`Olm not found for client ${client.clientId}, skipping peer updates`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const olmId = olm.olmId;
|
||||||
|
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Client ${client.clientId} missing subnet or pubKey, skipping peer updates`
|
`Client ${client.clientId} missing subnet or pubKey, skipping peer updates`
|
||||||
@@ -1021,9 +1016,9 @@ async function handleMessagesForClientSites(
|
|||||||
.leftJoin(newts, eq(sites.siteId, newts.siteId))
|
.leftJoin(newts, eq(sites.siteId, newts.siteId))
|
||||||
.where(inArray(sites.siteId, allSiteIds));
|
.where(inArray(sites.siteId, allSiteIds));
|
||||||
|
|
||||||
let newtJobs: Promise<any>[] = [];
|
const newtJobs: Promise<any>[] = [];
|
||||||
let olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
let exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
for (const siteData of sitesData) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
@@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources(
|
|||||||
resourcesToRemove: number[],
|
resourcesToRemove: number[],
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Group resources by site
|
const proxyJobs: Promise<any>[] = [];
|
||||||
const resourcesBySite = new Map<number, SiteResource[]>();
|
const olmJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
for (const resource of allNewResources) {
|
|
||||||
if (!resourcesBySite.has(resource.siteId)) {
|
|
||||||
resourcesBySite.set(resource.siteId, []);
|
|
||||||
}
|
|
||||||
resourcesBySite.get(resource.siteId)!.push(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
let proxyJobs: Promise<any>[] = [];
|
|
||||||
let olmJobs: Promise<any>[] = [];
|
|
||||||
|
|
||||||
// Handle additions
|
// Handle additions
|
||||||
if (resourcesToAdd.length > 0) {
|
if (resourcesToAdd.length > 0) {
|
||||||
|
|||||||
@@ -823,7 +823,7 @@ export async function getTraefikConfig(
|
|||||||
(cert) => cert.queriedDomain === lp.fullDomain
|
(cert) => cert.queriedDomain === lp.fullDomain
|
||||||
);
|
);
|
||||||
if (!matchingCert) {
|
if (!matchingCert) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`No matching certificate found for login page domain: ${lp.fullDomain}`
|
`No matching certificate found for login page domain: ${lp.fullDomain}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -84,14 +84,11 @@ LQIDAQAB
|
|||||||
-----END PUBLIC KEY-----`;
|
-----END PUBLIC KEY-----`;
|
||||||
|
|
||||||
constructor(private hostMeta: HostMeta) {
|
constructor(private hostMeta: HostMeta) {
|
||||||
setInterval(
|
setInterval(async () => {
|
||||||
async () => {
|
this.doRecheck = true;
|
||||||
this.doRecheck = true;
|
await this.check();
|
||||||
await this.check();
|
this.doRecheck = false;
|
||||||
this.doRecheck = false;
|
}, 1000 * this.phoneHomeInterval);
|
||||||
},
|
|
||||||
1000 * this.phoneHomeInterval
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public listKeys(): LicenseKeyCache[] {
|
public listKeys(): LicenseKeyCache[] {
|
||||||
@@ -242,7 +239,9 @@ LQIDAQAB
|
|||||||
// First failure: fail silently
|
// First failure: fail silently
|
||||||
logger.error("Error communicating with license server:");
|
logger.error("Error communicating with license server:");
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
logger.error(`Allowing failure. Will retry one more time at next run interval.`);
|
logger.error(
|
||||||
|
`Allowing failure. Will retry one more time at next run interval.`
|
||||||
|
);
|
||||||
// return last known good status
|
// return last known good status
|
||||||
return this.statusCache.get(
|
return this.statusCache.get(
|
||||||
this.statusKey
|
this.statusKey
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const queryAccessAuditLogsQuery = z.object({
|
|||||||
})
|
})
|
||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.prefault(new Date().toISOString())
|
.prefault(() => new Date().toISOString())
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "date-time",
|
format: "date-time",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const queryActionAuditLogsQuery = z.object({
|
|||||||
})
|
})
|
||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.prefault(new Date().toISOString())
|
.prefault(() => new Date().toISOString())
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "date-time",
|
format: "date-time",
|
||||||
|
|||||||
@@ -311,6 +311,33 @@ authenticated.get(
|
|||||||
loginPage.getLoginPage
|
loginPage.getLoginPage
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/login-page-branding",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getLoginPage),
|
||||||
|
logActionAudit(ActionsEnum.getLoginPage),
|
||||||
|
loginPage.getLoginPageBranding
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/login-page-branding",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||||
|
logActionAudit(ActionsEnum.updateLoginPage),
|
||||||
|
loginPage.upsertLoginPageBranding
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/login-page-branding",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteLoginPage),
|
||||||
|
logActionAudit(ActionsEnum.deleteLoginPage),
|
||||||
|
loginPage.deleteLoginPageBranding
|
||||||
|
);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/remoteExitNode/get-token",
|
"/remoteExitNode/get-token",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
|||||||
import { maxmindLookup } from "@server/db/maxmind";
|
import { maxmindLookup } from "@server/db/maxmind";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
|
||||||
|
|
||||||
// Zod schemas for request validation
|
// Zod schemas for request validation
|
||||||
const getResourceByDomainParamsSchema = z.strictObject({
|
const getResourceByDomainParamsSchema = z.strictObject({
|
||||||
@@ -1238,6 +1239,70 @@ hybridRouter.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const asnIpLookupParamsSchema = z.object({
|
||||||
|
ip: z.union([z.ipv4(), z.ipv6()])
|
||||||
|
});
|
||||||
|
hybridRouter.get(
|
||||||
|
"/asnip/:ip",
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsedParams = asnIpLookupParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ip } = parsedParams.data;
|
||||||
|
|
||||||
|
if (!maxmindAsnLookup) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.SERVICE_UNAVAILABLE,
|
||||||
|
"ASNIP service is not available"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = maxmindAsnLookup.get(ip);
|
||||||
|
|
||||||
|
if (!result || !result.autonomous_system_number) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"ASNIP information not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { autonomous_system_number } = result;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`ASNIP lookup successful for IP ${ip}: ${autonomous_system_number}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: { asn: autonomous_system_number },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "GeoIP lookup successful",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to validate resource session token"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// GERBIL ROUTERS
|
// GERBIL ROUTERS
|
||||||
const getConfigSchema = z.object({
|
const getConfigSchema = z.object({
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps);
|
|||||||
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
|
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
|
||||||
|
|
||||||
internalRouter.get("/login-page", loginPage.loadLoginPage);
|
internalRouter.get("/login-page", loginPage.loadLoginPage);
|
||||||
|
internalRouter.get("/login-page-branding", loginPage.loadLoginPageBranding);
|
||||||
|
|
||||||
internalRouter.post(
|
internalRouter.post(
|
||||||
"/get-session-transfer-token",
|
"/get-session-transfer-token",
|
||||||
|
|||||||
113
server/private/routers/loginPage/deleteLoginPageBranding.ts
Normal file
113
server/private/routers/loginPage/deleteLoginPageBranding.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 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 {
|
||||||
|
db,
|
||||||
|
LoginPageBranding,
|
||||||
|
loginPageBranding,
|
||||||
|
loginPageBrandingOrg
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function deleteLoginPageBranding(
|
||||||
|
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 } = parsedParams.data;
|
||||||
|
|
||||||
|
if (build === "saas") {
|
||||||
|
const { tier } = await getOrgTierData(orgId);
|
||||||
|
const subscribed = tier === TierId.STANDARD;
|
||||||
|
if (!subscribed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"This organization's current plan does not support this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingLoginPageBranding] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBranding)
|
||||||
|
.innerJoin(
|
||||||
|
loginPageBrandingOrg,
|
||||||
|
eq(
|
||||||
|
loginPageBrandingOrg.loginPageBrandingId,
|
||||||
|
loginPageBranding.loginPageBrandingId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(loginPageBrandingOrg.orgId, orgId));
|
||||||
|
|
||||||
|
if (!existingLoginPageBranding) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Login page branding not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(loginPageBranding)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
loginPageBranding.loginPageBrandingId,
|
||||||
|
existingLoginPageBranding.loginPageBranding
|
||||||
|
.loginPageBrandingId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<LoginPageBranding>(res, {
|
||||||
|
data: existingLoginPageBranding.loginPageBranding,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Login page branding deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
server/private/routers/loginPage/getLoginPageBranding.ts
Normal file
103
server/private/routers/loginPage/getLoginPageBranding.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 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 {
|
||||||
|
db,
|
||||||
|
LoginPageBranding,
|
||||||
|
loginPageBranding,
|
||||||
|
loginPageBrandingOrg
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
orgId: z.string()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function getLoginPageBranding(
|
||||||
|
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 } = parsedParams.data;
|
||||||
|
|
||||||
|
if (build === "saas") {
|
||||||
|
const { tier } = await getOrgTierData(orgId);
|
||||||
|
const subscribed = tier === TierId.STANDARD;
|
||||||
|
if (!subscribed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"This organization's current plan does not support this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingLoginPageBranding] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBranding)
|
||||||
|
.innerJoin(
|
||||||
|
loginPageBrandingOrg,
|
||||||
|
eq(
|
||||||
|
loginPageBrandingOrg.loginPageBrandingId,
|
||||||
|
loginPageBranding.loginPageBrandingId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(loginPageBrandingOrg.orgId, orgId));
|
||||||
|
|
||||||
|
if (!existingLoginPageBranding) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Login page branding not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<LoginPageBranding>(res, {
|
||||||
|
data: existingLoginPageBranding.loginPageBranding,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Login page branding retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,7 @@ export * from "./getLoginPage";
|
|||||||
export * from "./loadLoginPage";
|
export * from "./loadLoginPage";
|
||||||
export * from "./updateLoginPage";
|
export * from "./updateLoginPage";
|
||||||
export * from "./deleteLoginPage";
|
export * from "./deleteLoginPage";
|
||||||
|
export * from "./upsertLoginPageBranding";
|
||||||
|
export * from "./deleteLoginPageBranding";
|
||||||
|
export * from "./getLoginPageBranding";
|
||||||
|
export * from "./loadLoginPageBranding";
|
||||||
|
|||||||
100
server/private/routers/loginPage/loadLoginPageBranding.ts
Normal file
100
server/private/routers/loginPage/loadLoginPageBranding.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 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 { db, loginPageBranding, loginPageBrandingOrg, orgs } 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 type { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
orgId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
async function query(orgId: string) {
|
||||||
|
const [orgLink] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBrandingOrg)
|
||||||
|
.where(eq(loginPageBrandingOrg.orgId, orgId))
|
||||||
|
.innerJoin(orgs, eq(loginPageBrandingOrg.orgId, orgs.orgId));
|
||||||
|
if (!orgLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBranding)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
loginPageBranding.loginPageBrandingId,
|
||||||
|
orgLink.loginPageBrandingOrg.loginPageBrandingId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
orgId: orgLink.orgs.orgId,
|
||||||
|
orgName: orgLink.orgs.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLoginPageBranding(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedQuery.data;
|
||||||
|
|
||||||
|
const branding = await query(orgId);
|
||||||
|
|
||||||
|
if (!branding) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Branding for Login page not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<LoadLoginPageBrandingResponse>(res, {
|
||||||
|
data: branding,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Login page branding retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal file
162
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 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 {
|
||||||
|
db,
|
||||||
|
LoginPageBranding,
|
||||||
|
loginPageBranding,
|
||||||
|
loginPageBrandingOrg
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
logoUrl: z.url(),
|
||||||
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
|
resourceTitle: z.string(),
|
||||||
|
resourceSubtitle: z.string().optional(),
|
||||||
|
orgTitle: z.string().optional(),
|
||||||
|
orgSubtitle: z.string().optional(),
|
||||||
|
primaryColor: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateLoginPageBrandingBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export async function upsertLoginPageBranding(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (build === "saas") {
|
||||||
|
const { tier } = await getOrgTierData(orgId);
|
||||||
|
const subscribed = tier === TierId.STANDARD;
|
||||||
|
if (!subscribed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"This organization's current plan does not support this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateData = parsedBody.data satisfies InferInsertModel<
|
||||||
|
typeof loginPageBranding
|
||||||
|
>;
|
||||||
|
|
||||||
|
if (build !== "saas") {
|
||||||
|
// org branding settings are only considered in the saas build
|
||||||
|
const { orgTitle, orgSubtitle, ...rest } = updateData;
|
||||||
|
updateData = rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingLoginPageBranding] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBranding)
|
||||||
|
.innerJoin(
|
||||||
|
loginPageBrandingOrg,
|
||||||
|
eq(
|
||||||
|
loginPageBrandingOrg.loginPageBrandingId,
|
||||||
|
loginPageBranding.loginPageBrandingId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(loginPageBrandingOrg.orgId, orgId));
|
||||||
|
|
||||||
|
let updatedLoginPageBranding: LoginPageBranding;
|
||||||
|
|
||||||
|
if (existingLoginPageBranding) {
|
||||||
|
updatedLoginPageBranding = await db.transaction(async (tx) => {
|
||||||
|
const [branding] = await tx
|
||||||
|
.update(loginPageBranding)
|
||||||
|
.set({ ...updateData })
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
loginPageBranding.loginPageBrandingId,
|
||||||
|
existingLoginPageBranding.loginPageBranding
|
||||||
|
.loginPageBrandingId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return branding;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updatedLoginPageBranding = await db.transaction(async (tx) => {
|
||||||
|
const [branding] = await tx
|
||||||
|
.insert(loginPageBranding)
|
||||||
|
.values({ ...updateData })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await tx.insert(loginPageBrandingOrg).values({
|
||||||
|
loginPageBrandingId: branding.loginPageBrandingId,
|
||||||
|
orgId: orgId
|
||||||
|
});
|
||||||
|
return branding;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<LoginPageBranding>(res, {
|
||||||
|
data: updatedLoginPageBranding,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: existingLoginPageBranding
|
||||||
|
? "Login page branding updated successfully"
|
||||||
|
: "Login page branding created successfully",
|
||||||
|
status: existingLoginPageBranding ? HttpCode.OK : HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ export async function sendSupportEmail(
|
|||||||
{
|
{
|
||||||
name: req.user?.email || "Support User",
|
name: req.user?.email || "Support User",
|
||||||
to: "support@pangolin.net",
|
to: "support@pangolin.net",
|
||||||
|
replyTo: req.user?.email || undefined,
|
||||||
from: config.getNoReplyEmail(),
|
from: config.getNoReplyEmail(),
|
||||||
subject: `Support Request: ${subject}`
|
subject: `Support Request: ${subject}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, requestAuditLog, driver } from "@server/db";
|
import { db, requestAuditLog, driver, primaryDb } from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
@@ -35,7 +35,7 @@ const queryAccessAuditLogsQuery = z.object({
|
|||||||
})
|
})
|
||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.prefault(new Date().toISOString())
|
.prefault(() => new Date().toISOString())
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "date-time",
|
format: "date-time",
|
||||||
@@ -74,12 +74,12 @@ async function query(query: Q) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [all] = await db
|
const [all] = await primaryDb
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions);
|
.where(baseConditions);
|
||||||
|
|
||||||
const [blocked] = await db
|
const [blocked] = await primaryDb
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
||||||
@@ -88,7 +88,9 @@ async function query(query: Q) {
|
|||||||
.mapWith(Number)
|
.mapWith(Number)
|
||||||
.as("total");
|
.as("total");
|
||||||
|
|
||||||
const requestsPerCountry = await db
|
const DISTINCT_LIMIT = 500;
|
||||||
|
|
||||||
|
const requestsPerCountry = await primaryDb
|
||||||
.selectDistinct({
|
.selectDistinct({
|
||||||
code: requestAuditLog.location,
|
code: requestAuditLog.location,
|
||||||
count: totalQ
|
count: totalQ
|
||||||
@@ -96,7 +98,16 @@ async function query(query: Q) {
|
|||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(and(baseConditions, not(isNull(requestAuditLog.location))))
|
.where(and(baseConditions, not(isNull(requestAuditLog.location))))
|
||||||
.groupBy(requestAuditLog.location)
|
.groupBy(requestAuditLog.location)
|
||||||
.orderBy(desc(totalQ));
|
.orderBy(desc(totalQ))
|
||||||
|
.limit(DISTINCT_LIMIT+1);
|
||||||
|
|
||||||
|
if (requestsPerCountry.length > DISTINCT_LIMIT) {
|
||||||
|
// throw an error
|
||||||
|
throw createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Too many distinct countries. Please narrow your query.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const groupByDayFunction =
|
const groupByDayFunction =
|
||||||
driver === "pg"
|
driver === "pg"
|
||||||
@@ -106,7 +117,7 @@ async function query(query: Q) {
|
|||||||
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
|
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
|
||||||
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
|
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
|
||||||
|
|
||||||
const requestsPerDay = await db
|
const requestsPerDay = await primaryDb
|
||||||
.select({
|
.select({
|
||||||
day: groupByDayFunction.as("day"),
|
day: groupByDayFunction.as("day"),
|
||||||
allowedCount:
|
allowedCount:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, requestAuditLog, resources } from "@server/db";
|
import { db, primaryDb, requestAuditLog, resources } from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
@@ -35,7 +35,7 @@ export const queryAccessAuditLogsQuery = z.object({
|
|||||||
})
|
})
|
||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.prefault(new Date().toISOString())
|
.prefault(() => new Date().toISOString())
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
format: "date-time",
|
format: "date-time",
|
||||||
@@ -107,7 +107,7 @@ function getWhere(data: Q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function queryRequest(data: Q) {
|
export function queryRequest(data: Q) {
|
||||||
return db
|
return primaryDb
|
||||||
.select({
|
.select({
|
||||||
id: requestAuditLog.id,
|
id: requestAuditLog.id,
|
||||||
timestamp: requestAuditLog.timestamp,
|
timestamp: requestAuditLog.timestamp,
|
||||||
@@ -143,7 +143,7 @@ export function queryRequest(data: Q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countRequestQuery(data: Q) {
|
export function countRequestQuery(data: Q) {
|
||||||
const countQuery = db
|
const countQuery = primaryDb
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(getWhere(data));
|
.where(getWhere(data));
|
||||||
@@ -173,50 +173,61 @@ async function queryUniqueFilterAttributes(
|
|||||||
eq(requestAuditLog.orgId, orgId)
|
eq(requestAuditLog.orgId, orgId)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get unique actors
|
const DISTINCT_LIMIT = 500;
|
||||||
const uniqueActors = await db
|
|
||||||
.selectDistinct({
|
|
||||||
actor: requestAuditLog.actor
|
|
||||||
})
|
|
||||||
.from(requestAuditLog)
|
|
||||||
.where(baseConditions);
|
|
||||||
|
|
||||||
// Get unique locations
|
// TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!!
|
||||||
const uniqueLocations = await db
|
|
||||||
.selectDistinct({
|
|
||||||
locations: requestAuditLog.location
|
|
||||||
})
|
|
||||||
.from(requestAuditLog)
|
|
||||||
.where(baseConditions);
|
|
||||||
|
|
||||||
// Get unique actors
|
// Run all queries in parallel
|
||||||
const uniqueHosts = await db
|
const [
|
||||||
.selectDistinct({
|
uniqueActors,
|
||||||
hosts: requestAuditLog.host
|
uniqueLocations,
|
||||||
})
|
uniqueHosts,
|
||||||
.from(requestAuditLog)
|
uniquePaths,
|
||||||
.where(baseConditions);
|
uniqueResources
|
||||||
|
] = await Promise.all([
|
||||||
|
primaryDb
|
||||||
|
.selectDistinct({ actor: requestAuditLog.actor })
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT+1),
|
||||||
|
primaryDb
|
||||||
|
.selectDistinct({ locations: requestAuditLog.location })
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT+1),
|
||||||
|
primaryDb
|
||||||
|
.selectDistinct({ hosts: requestAuditLog.host })
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT+1),
|
||||||
|
primaryDb
|
||||||
|
.selectDistinct({ paths: requestAuditLog.path })
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT+1),
|
||||||
|
primaryDb
|
||||||
|
.selectDistinct({
|
||||||
|
id: requestAuditLog.resourceId,
|
||||||
|
name: resources.name
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.leftJoin(
|
||||||
|
resources,
|
||||||
|
eq(requestAuditLog.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT+1)
|
||||||
|
]);
|
||||||
|
|
||||||
// Get unique actors
|
if (
|
||||||
const uniquePaths = await db
|
uniqueActors.length > DISTINCT_LIMIT ||
|
||||||
.selectDistinct({
|
uniqueLocations.length > DISTINCT_LIMIT ||
|
||||||
paths: requestAuditLog.path
|
uniqueHosts.length > DISTINCT_LIMIT ||
|
||||||
})
|
uniquePaths.length > DISTINCT_LIMIT ||
|
||||||
.from(requestAuditLog)
|
uniqueResources.length > DISTINCT_LIMIT
|
||||||
.where(baseConditions);
|
) {
|
||||||
|
throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range.");
|
||||||
// Get unique resources with names
|
}
|
||||||
const uniqueResources = await db
|
|
||||||
.selectDistinct({
|
|
||||||
id: requestAuditLog.resourceId,
|
|
||||||
name: resources.name
|
|
||||||
})
|
|
||||||
.from(requestAuditLog)
|
|
||||||
.leftJoin(
|
|
||||||
resources,
|
|
||||||
eq(requestAuditLog.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.where(baseConditions);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actors: uniqueActors
|
actors: uniqueActors
|
||||||
@@ -295,6 +306,12 @@ export async function queryRequestAuditLogs(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
// if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message
|
||||||
|
if (error instanceof Error && error.message === "Too many distinct filter attributes to retrieve. Please refine your time range.") {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, error.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logRequestAudit(
|
export async function logRequestAudit(
|
||||||
data: {
|
data: {
|
||||||
action: boolean;
|
action: boolean;
|
||||||
reason: number;
|
reason: number;
|
||||||
@@ -174,14 +174,13 @@ export function logRequestAudit(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Quick synchronous check - if org has 0 retention, skip immediately
|
// Check retention before buffering any logs
|
||||||
if (data.orgId) {
|
if (data.orgId) {
|
||||||
const cached = cache.get<number>(`org_${data.orgId}_retentionDays`);
|
const retentionDays = await getRetentionDays(data.orgId);
|
||||||
if (cached === 0) {
|
if (retentionDays === 0) {
|
||||||
// do not log
|
// do not log
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If not cached or > 0, we'll log it (async retention check happens in background)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let actorType: string | undefined;
|
let actorType: string | undefined;
|
||||||
@@ -261,16 +260,6 @@ export function logRequestAudit(
|
|||||||
} else {
|
} else {
|
||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Async retention check in background (don't await)
|
|
||||||
if (
|
|
||||||
data.orgId &&
|
|
||||||
cache.get<number>(`org_${data.orgId}_retentionDays`) === undefined
|
|
||||||
) {
|
|
||||||
getRetentionDays(data.orgId).catch((err) =>
|
|
||||||
logger.error("Error checking retention days:", err)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import createHttpError from "http-errors";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
|
import { getAsnForIp } from "@server/lib/asn";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
@@ -128,6 +129,10 @@ export async function verifyResourceSession(
|
|||||||
? await getCountryCodeFromIp(clientIp)
|
? await getCountryCodeFromIp(clientIp)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const ipAsn = clientIp
|
||||||
|
? await getAsnFromIp(clientIp)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
let cleanHost = host;
|
let cleanHost = host;
|
||||||
// if the host ends with :port, strip it
|
// if the host ends with :port, strip it
|
||||||
if (cleanHost.match(/:[0-9]{1,5}$/)) {
|
if (cleanHost.match(/:[0-9]{1,5}$/)) {
|
||||||
@@ -216,7 +221,8 @@ export async function verifyResourceSession(
|
|||||||
resource.resourceId,
|
resource.resourceId,
|
||||||
clientIp,
|
clientIp,
|
||||||
path,
|
path,
|
||||||
ipCC
|
ipCC,
|
||||||
|
ipAsn
|
||||||
);
|
);
|
||||||
|
|
||||||
if (action == "ACCEPT") {
|
if (action == "ACCEPT") {
|
||||||
@@ -910,7 +916,8 @@ async function checkRules(
|
|||||||
resourceId: number,
|
resourceId: number,
|
||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
path: string | undefined,
|
path: string | undefined,
|
||||||
ipCC?: string
|
ipCC?: string,
|
||||||
|
ipAsn?: number
|
||||||
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
||||||
const ruleCacheKey = `rules:${resourceId}`;
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
@@ -954,6 +961,12 @@ async function checkRules(
|
|||||||
(await isIpInGeoIP(ipCC, rule.value))
|
(await isIpInGeoIP(ipCC, rule.value))
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
clientIp &&
|
||||||
|
rule.match == "ASN" &&
|
||||||
|
(await isIpInAsn(ipAsn, rule.value))
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,6 +1103,52 @@ async function isIpInGeoIP(
|
|||||||
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isIpInAsn(
|
||||||
|
ipAsn: number | undefined,
|
||||||
|
checkAsn: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Handle "ALL" special case
|
||||||
|
if (checkAsn === "ALL" || checkAsn === "AS0") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ipAsn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the check ASN - remove "AS" prefix if present and convert to number
|
||||||
|
const normalizedCheckAsn = checkAsn.toUpperCase().replace(/^AS/, "");
|
||||||
|
const checkAsnNumber = parseInt(normalizedCheckAsn, 10);
|
||||||
|
|
||||||
|
if (isNaN(checkAsnNumber)) {
|
||||||
|
logger.warn(`Invalid ASN format in rule: ${checkAsn}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = ipAsn === checkAsnNumber;
|
||||||
|
logger.debug(
|
||||||
|
`ASN check: IP ASN ${ipAsn} ${match ? "matches" : "does not match"} rule ASN ${checkAsnNumber}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAsnFromIp(ip: string): Promise<number | undefined> {
|
||||||
|
const asnCacheKey = `asn:${ip}`;
|
||||||
|
|
||||||
|
let cachedAsn: number | undefined = cache.get(asnCacheKey);
|
||||||
|
|
||||||
|
if (!cachedAsn) {
|
||||||
|
cachedAsn = await getAsnForIp(ip); // do it locally
|
||||||
|
// Cache for longer since IP ASN doesn't change frequently
|
||||||
|
if (cachedAsn) {
|
||||||
|
cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedAsn;
|
||||||
|
}
|
||||||
|
|
||||||
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
||||||
const geoIpCacheKey = `geoip:${ip}`;
|
const geoIpCacheKey = `geoip:${ip}`;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export type GetCertificateResponse = {
|
|||||||
status: string; // pending, requested, valid, expired, failed
|
status: string; // pending, requested, valid, expired, failed
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastRenewalAttempt: Date | null;
|
lastRenewalAttempt: Date | null;
|
||||||
createdAt: string;
|
createdAt: number;
|
||||||
updatedAt: string;
|
updatedAt: number;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
renewalCount: number;
|
renewalCount: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,21 +4,48 @@ import { Alias, SubnetProxyTarget } from "@server/lib/ip";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
const BATCH_DELAY_MS = 50;
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < array.length; i += size) {
|
||||||
|
chunks.push(array.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
|
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
|
||||||
await sendToClient(newtId, {
|
const batches = chunkArray(targets, BATCH_SIZE);
|
||||||
type: `newt/wg/targets/add`,
|
for (let i = 0; i < batches.length; i++) {
|
||||||
data: targets
|
if (i > 0) {
|
||||||
});
|
await sleep(BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: `newt/wg/targets/add`,
|
||||||
|
data: batches[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: SubnetProxyTarget[]
|
targets: SubnetProxyTarget[]
|
||||||
) {
|
) {
|
||||||
await sendToClient(newtId, {
|
const batches = chunkArray(targets, BATCH_SIZE);
|
||||||
type: `newt/wg/targets/remove`,
|
for (let i = 0; i < batches.length; i++) {
|
||||||
data: targets
|
if (i > 0) {
|
||||||
});
|
await sleep(BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: `newt/wg/targets/remove`,
|
||||||
|
data: batches[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTargets(
|
export async function updateTargets(
|
||||||
@@ -28,12 +55,24 @@ export async function updateTargets(
|
|||||||
newTargets: SubnetProxyTarget[];
|
newTargets: SubnetProxyTarget[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await sendToClient(newtId, {
|
const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
|
||||||
type: `newt/wg/targets/update`,
|
const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
|
||||||
data: targets
|
const maxBatches = Math.max(oldBatches.length, newBatches.length);
|
||||||
}).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
for (let i = 0; i < maxBatches; i++) {
|
||||||
});
|
if (i > 0) {
|
||||||
|
await sleep(BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: `newt/wg/targets/update`,
|
||||||
|
data: {
|
||||||
|
oldTargets: oldBatches[i] || [],
|
||||||
|
newTargets: newBatches[i] || []
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPeerData(
|
export async function addPeerData(
|
||||||
|
|||||||
@@ -239,9 +239,8 @@ authenticated.get(
|
|||||||
|
|
||||||
// Site Resource endpoints
|
// Site Resource endpoints
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site/:siteId/resource",
|
"/org/:orgId/site-resource",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifySiteAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.createSiteResource),
|
verifyUserHasAction(ActionsEnum.createSiteResource),
|
||||||
logActionAudit(ActionsEnum.createSiteResource),
|
logActionAudit(ActionsEnum.createSiteResource),
|
||||||
siteResource.createSiteResource
|
siteResource.createSiteResource
|
||||||
@@ -263,18 +262,14 @@ authenticated.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyOrgAccess,
|
|
||||||
verifySiteAccess,
|
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getSiteResource),
|
verifyUserHasAction(ActionsEnum.getSiteResource),
|
||||||
siteResource.getSiteResource
|
siteResource.getSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyOrgAccess,
|
|
||||||
verifySiteAccess,
|
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
||||||
logActionAudit(ActionsEnum.updateSiteResource),
|
logActionAudit(ActionsEnum.updateSiteResource),
|
||||||
@@ -282,9 +277,7 @@ authenticated.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyOrgAccess,
|
|
||||||
verifySiteAccess,
|
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyUserHasAction(ActionsEnum.deleteSiteResource),
|
verifyUserHasAction(ActionsEnum.deleteSiteResource),
|
||||||
logActionAudit(ActionsEnum.deleteSiteResource),
|
logActionAudit(ActionsEnum.deleteSiteResource),
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export async function getConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitNode = await createExitNode(publicKey, reachableAt);
|
// clean up the public key - keep only valid base64 characters (A-Z, a-z, 0-9, +, /, =)
|
||||||
|
const cleanedPublicKey = publicKey.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||||
|
|
||||||
|
const exitNode = await createExitNode(cleanedPublicKey, reachableAt);
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -192,11 +192,71 @@ export async function validateOidcCallback(
|
|||||||
state
|
state
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = await client.validateAuthorizationCode(
|
let tokens: arctic.OAuth2Tokens;
|
||||||
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
|
try {
|
||||||
code,
|
tokens = await client.validateAuthorizationCode(
|
||||||
codeVerifier
|
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
|
||||||
);
|
code,
|
||||||
|
codeVerifier
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof arctic.OAuth2RequestError) {
|
||||||
|
logger.warn("OIDC provider rejected the authorization code", {
|
||||||
|
error: err.code,
|
||||||
|
description: err.description,
|
||||||
|
uri: err.uri,
|
||||||
|
state: err.state
|
||||||
|
});
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.UNAUTHORIZED,
|
||||||
|
err.description ||
|
||||||
|
`OIDC provider rejected the request (${err.code})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof arctic.UnexpectedResponseError) {
|
||||||
|
logger.error(
|
||||||
|
"OIDC provider returned an unexpected response during token exchange",
|
||||||
|
{ status: err.status }
|
||||||
|
);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_GATEWAY,
|
||||||
|
"Received an unexpected response from the identity provider while exchanging the authorization code."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof arctic.UnexpectedErrorResponseBodyError) {
|
||||||
|
logger.error(
|
||||||
|
"OIDC provider returned an unexpected error payload during token exchange",
|
||||||
|
{ status: err.status, data: err.data }
|
||||||
|
);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_GATEWAY,
|
||||||
|
"Identity provider returned an unexpected error payload while exchanging the authorization code."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof arctic.ArcticFetchError) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to reach OIDC provider while exchanging authorization code",
|
||||||
|
{ error: err.message }
|
||||||
|
);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_GATEWAY,
|
||||||
|
"Unable to reach the identity provider while exchanging the authorization code. Please try again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const idToken = tokens.idToken();
|
const idToken = tokens.idToken();
|
||||||
logger.debug("ID token", { idToken });
|
logger.debug("ID token", { idToken });
|
||||||
@@ -545,9 +605,18 @@ export async function validateOidcCallback(
|
|||||||
|
|
||||||
res.appendHeader("Set-Cookie", cookie);
|
res.appendHeader("Set-Cookie", cookie);
|
||||||
|
|
||||||
|
let finalRedirectUrl = postAuthRedirectUrl;
|
||||||
|
if (loginPageId) {
|
||||||
|
finalRedirectUrl = `/auth/org/?redirect=${encodeURIComponent(
|
||||||
|
postAuthRedirectUrl
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Final redirect URL", { finalRedirectUrl });
|
||||||
|
|
||||||
return response<ValidateOidcUrlCallbackResponse>(res, {
|
return response<ValidateOidcUrlCallbackResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
redirectUrl: postAuthRedirectUrl
|
redirectUrl: finalRedirectUrl
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -146,9 +146,8 @@ authenticated.get(
|
|||||||
);
|
);
|
||||||
// Site Resource endpoints
|
// Site Resource endpoints
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site/:siteId/resource",
|
"/org/:orgId/private-resource",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeySiteAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
|
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
|
||||||
logActionAudit(ActionsEnum.createSiteResource),
|
logActionAudit(ActionsEnum.createSiteResource),
|
||||||
siteResource.createSiteResource
|
siteResource.createSiteResource
|
||||||
@@ -170,18 +169,14 @@ authenticated.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeySiteAccess,
|
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.getSiteResource),
|
verifyApiKeyHasAction(ActionsEnum.getSiteResource),
|
||||||
siteResource.getSiteResource
|
siteResource.getSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeySiteAccess,
|
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
|
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
|
||||||
logActionAudit(ActionsEnum.updateSiteResource),
|
logActionAudit(ActionsEnum.updateSiteResource),
|
||||||
@@ -189,9 +184,7 @@ authenticated.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/org/:orgId/site/:siteId/resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeySiteAccess,
|
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.deleteSiteResource),
|
verifyApiKeyHasAction(ActionsEnum.deleteSiteResource),
|
||||||
logActionAudit(ActionsEnum.deleteSiteResource),
|
logActionAudit(ActionsEnum.deleteSiteResource),
|
||||||
@@ -352,6 +345,14 @@ authenticated.post(
|
|||||||
user.inviteUser
|
user.inviteUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/invitations/:inviteId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.removeInvitation),
|
||||||
|
logActionAudit(ActionsEnum.removeInvitation),
|
||||||
|
user.removeInvitation
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId/roles",
|
"/resource/:resourceId/roles",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
@@ -857,6 +858,22 @@ authenticated.put(
|
|||||||
blueprints.applyJSONBlueprint
|
blueprints.applyJSONBlueprint
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/blueprint/:blueprintId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getBlueprint),
|
||||||
|
blueprints.getBlueprint
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/blueprints",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listBlueprints),
|
||||||
|
blueprints.listBlueprints
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/request",
|
"/org/:orgId/logs/request",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LoginPage } from "@server/db";
|
import type { LoginPage, LoginPageBranding } from "@server/db";
|
||||||
|
|
||||||
export type CreateLoginPageResponse = LoginPage;
|
export type CreateLoginPageResponse = LoginPage;
|
||||||
|
|
||||||
@@ -9,3 +9,10 @@ export type GetLoginPageResponse = LoginPage;
|
|||||||
export type UpdateLoginPageResponse = LoginPage;
|
export type UpdateLoginPageResponse = LoginPage;
|
||||||
|
|
||||||
export type LoadLoginPageResponse = LoginPage & { orgId: string };
|
export type LoadLoginPageResponse = LoginPage & { orgId: string };
|
||||||
|
|
||||||
|
export type LoadLoginPageBrandingResponse = LoginPageBranding & {
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetLoginPageBrandingResponse = LoginPageBranding;
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
type: "newt/wg/connect",
|
type: "newt/wg/connect",
|
||||||
data: {
|
data: {
|
||||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
serverIP: exitNode.address.split("/")[0],
|
serverIP: exitNode.address.split("/")[0],
|
||||||
tunnelIP: siteSubnet.split("/")[0],
|
tunnelIP: siteSubnet.split("/")[0],
|
||||||
|
|||||||
@@ -194,10 +194,23 @@ export async function getOlmToken(
|
|||||||
.where(inArray(exitNodes.exitNodeId, exitNodeIds));
|
.where(inArray(exitNodes.exitNodeId, exitNodeIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map exitNodeId to siteIds
|
||||||
|
const exitNodeIdToSiteIds: Record<number, number[]> = {};
|
||||||
|
for (const { sites: site } of clientSites) {
|
||||||
|
if (site.exitNodeId !== null) {
|
||||||
|
if (!exitNodeIdToSiteIds[site.exitNodeId]) {
|
||||||
|
exitNodeIdToSiteIds[site.exitNodeId] = [];
|
||||||
|
}
|
||||||
|
exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||||
return {
|
return {
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
endpoint: exitNode.endpoint
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
|
endpoint: exitNode.endpoint,
|
||||||
|
siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { clients, clientSitesAssociationsCache, Olm } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { updatePeer as newtUpdatePeer } from "../newt/peers";
|
import { updatePeer as newtUpdatePeer } from "../newt/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
@@ -88,7 +89,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
type: "olm/wg/peer/relay",
|
type: "olm/wg/peer/relay",
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
relayEndpoint: exitNode.endpoint
|
relayEndpoint: exitNode.endpoint,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms } from "@server/db";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Alias } from "yaml";
|
import { Alias } from "yaml";
|
||||||
@@ -156,6 +157,7 @@ export async function initPeerAddHandshake(
|
|||||||
siteId: peer.siteId,
|
siteId: peer.siteId,
|
||||||
exitNode: {
|
exitNode: {
|
||||||
publicKey: peer.exitNode.publicKey,
|
publicKey: peer.exitNode.publicKey,
|
||||||
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
endpoint: peer.exitNode.endpoint
|
endpoint: peer.exitNode.endpoint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { usageService } from "@server/lib/billing/usageService";
|
|||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
|
import { doCidrsOverlap } from "@server/lib/ip";
|
||||||
|
|
||||||
const createOrgSchema = z.strictObject({
|
const createOrgSchema = z.strictObject({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
@@ -36,6 +37,11 @@ const createOrgSchema = z.strictObject({
|
|||||||
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||||
.refine((val) => isValidCIDR(val), {
|
.refine((val) => isValidCIDR(val), {
|
||||||
message: "Invalid subnet CIDR"
|
message: "Invalid subnet CIDR"
|
||||||
|
}),
|
||||||
|
utilitySubnet: z
|
||||||
|
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||||
|
.refine((val) => isValidCIDR(val), {
|
||||||
|
message: "Invalid utility subnet CIDR"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +90,7 @@ export async function createOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orgId, name, subnet } = parsedBody.data;
|
const { orgId, name, subnet, utilitySubnet } = parsedBody.data;
|
||||||
|
|
||||||
// TODO: for now we are making all of the orgs the same subnet
|
// TODO: for now we are making all of the orgs the same subnet
|
||||||
// make sure the subnet is unique
|
// make sure the subnet is unique
|
||||||
@@ -119,6 +125,15 @@ export async function createOrg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (doCidrsOverlap(subnet, utilitySubnet)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Subnet ${subnet} overlaps with utility subnet ${utilitySubnet}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let error = "";
|
let error = "";
|
||||||
let org: Org | null = null;
|
let org: Org | null = null;
|
||||||
|
|
||||||
@@ -128,9 +143,6 @@ export async function createOrg(
|
|||||||
.from(domains)
|
.from(domains)
|
||||||
.where(eq(domains.configManaged, true));
|
.where(eq(domains.configManaged, true));
|
||||||
|
|
||||||
const utilitySubnet =
|
|
||||||
config.getRawConfig().orgs.utility_subnet_group;
|
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
.insert(orgs)
|
.insert(orgs)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import config from "@server/lib/config";
|
|||||||
|
|
||||||
export type PickOrgDefaultsResponse = {
|
export type PickOrgDefaultsResponse = {
|
||||||
subnet: string;
|
subnet: string;
|
||||||
|
utilitySubnet: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function pickOrgDefaults(
|
export async function pickOrgDefaults(
|
||||||
@@ -20,10 +21,13 @@ export async function pickOrgDefaults(
|
|||||||
// const subnet = await getNextAvailableOrgSubnet();
|
// const subnet = await getNextAvailableOrgSubnet();
|
||||||
// Just hard code the subnet for now for everyone
|
// Just hard code the subnet for now for everyone
|
||||||
const subnet = config.getRawConfig().orgs.subnet_group;
|
const subnet = config.getRawConfig().orgs.subnet_group;
|
||||||
|
const utilitySubnet =
|
||||||
|
config.getRawConfig().orgs.utility_subnet_group;
|
||||||
|
|
||||||
return response<PickOrgDefaultsResponse>(res, {
|
return response<PickOrgDefaultsResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
subnet: subnet
|
subnet: subnet,
|
||||||
|
utilitySubnet: utilitySubnet
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
|
|
||||||
const createResourceRuleSchema = z.strictObject({
|
const createResourceRuleSchema = z.strictObject({
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||||
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]),
|
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
priority: z.int(),
|
priority: z.int(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export async function getResourceAuthInfo(
|
|||||||
resourcePassword,
|
resourcePassword,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
eq(
|
eq(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const updateResourceRuleParamsSchema = z.strictObject({
|
|||||||
const updateResourceRuleSchema = z
|
const updateResourceRuleSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
||||||
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(),
|
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(),
|
||||||
value: z.string().min(1).optional(),
|
value: z.string().min(1).optional(),
|
||||||
priority: z.int(),
|
priority: z.int(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function deleteSite(
|
|||||||
// Send termination message outside of transaction to prevent blocking
|
// Send termination message outside of transaction to prevent blocking
|
||||||
if (deletedNewtId) {
|
if (deletedNewtId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
type: `newt/terminate`,
|
type: `newt/wg/terminate`,
|
||||||
data: {}
|
data: {}
|
||||||
};
|
};
|
||||||
// Don't await this to prevent blocking the response
|
// Don't await this to prevent blocking the response
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db, exitNodes, newts } from "@server/db";
|
import { db, exitNodes, newts } from "@server/db";
|
||||||
import { orgs, roleSites, sites, userSites } from "@server/db";
|
import { orgs, roleSites, sites, userSites } from "@server/db";
|
||||||
|
import { remoteExitNodes } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -104,12 +105,17 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
|||||||
newtVersion: newts.version,
|
newtVersion: newts.version,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
exitNodeName: exitNodes.name,
|
exitNodeName: exitNodes.name,
|
||||||
exitNodeEndpoint: exitNodes.endpoint
|
exitNodeEndpoint: exitNodes.endpoint,
|
||||||
|
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
.leftJoin(newts, eq(newts.siteId, sites.siteId))
|
.leftJoin(newts, eq(newts.siteId, sites.siteId))
|
||||||
.leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId))
|
.leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId))
|
||||||
|
.leftJoin(
|
||||||
|
remoteExitNodes,
|
||||||
|
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
inArray(sites.siteId, accessibleSiteIds),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
db,
|
db,
|
||||||
newts,
|
newts,
|
||||||
|
orgs,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||||
import { getNextAvailableAliasAddress } from "@server/lib/ip";
|
import { getNextAvailableAliasAddress, isIpInCidr, portRangeStringSchema } from "@server/lib/ip";
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -23,7 +24,6 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const createSiteResourceParamsSchema = z.strictObject({
|
const createSiteResourceParamsSchema = z.strictObject({
|
||||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ const createSiteResourceSchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr", "port"]),
|
mode: z.enum(["host", "cidr", "port"]),
|
||||||
|
siteId: z.int(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
// destinationPort: z.int().positive().optional(),
|
// destinationPort: z.int().positive().optional(),
|
||||||
@@ -39,13 +40,16 @@ const createSiteResourceSchema = z
|
|||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
|
/^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
|
||||||
"Alias must be a fully qualified domain name (e.g., example.com)"
|
"Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)"
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()),
|
||||||
roleIds: z.array(z.int()),
|
roleIds: z.array(z.int()),
|
||||||
clientIds: z.array(z.int())
|
clientIds: z.array(z.int()),
|
||||||
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
|
udpPortRangeString: portRangeStringSchema,
|
||||||
|
disableIcmp: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -65,7 +69,7 @@ const createSiteResourceSchema = z
|
|||||||
const domainRegex =
|
const domainRegex =
|
||||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||||
const isValidDomain = domainRegex.test(data.destination);
|
const isValidDomain = domainRegex.test(data.destination);
|
||||||
const isValidAlias = data.alias && domainRegex.test(data.alias);
|
const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== "";
|
||||||
|
|
||||||
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
||||||
}
|
}
|
||||||
@@ -81,8 +85,7 @@ const createSiteResourceSchema = z
|
|||||||
if (data.mode === "cidr") {
|
if (data.mode === "cidr") {
|
||||||
// Check if it's a valid CIDR (v4 or v6)
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
const isValidCIDR = z
|
const isValidCIDR = z
|
||||||
// .union([z.cidrv4(), z.cidrv6()])
|
.union([z.cidrv4(), z.cidrv6()])
|
||||||
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
|
||||||
.safeParse(data.destination).success;
|
.safeParse(data.destination).success;
|
||||||
return isValidCIDR;
|
return isValidCIDR;
|
||||||
}
|
}
|
||||||
@@ -98,7 +101,7 @@ export type CreateSiteResourceResponse = SiteResource;
|
|||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "put",
|
method: "put",
|
||||||
path: "/org/{orgId}/site/{siteId}/resource",
|
path: "/org/{orgId}/site-resource",
|
||||||
description: "Create a new site resource.",
|
description: "Create a new site resource.",
|
||||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
@@ -142,9 +145,10 @@ export async function createSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId, orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
siteId,
|
||||||
mode,
|
mode,
|
||||||
// protocol,
|
// protocol,
|
||||||
// proxyPort,
|
// proxyPort,
|
||||||
@@ -154,7 +158,10 @@ export async function createSiteResource(
|
|||||||
alias,
|
alias,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
clientIds
|
clientIds,
|
||||||
|
tcpPortRangeString,
|
||||||
|
udpPortRangeString,
|
||||||
|
disableIcmp
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
@@ -168,6 +175,39 @@ export async function createSiteResource(
|
|||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [org] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org.subnet || !org.utilitySubnet) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Organization with ID ${orgId} has no subnet or utilitySubnet defined defined`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check if destination is an IP address
|
||||||
|
const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success;
|
||||||
|
if (
|
||||||
|
isIp &&
|
||||||
|
(isIpInCidr(destination, org.subnet) || isIpInCidr(destination, org.utilitySubnet))
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"IP can not be in the CIDR range of the organization's subnet or utility subnet"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// // check if resource with same protocol and proxy port already exists (only for port mode)
|
// // check if resource with same protocol and proxy port already exists (only for port mode)
|
||||||
// if (mode === "port" && protocol && proxyPort) {
|
// if (mode === "port" && protocol && proxyPort) {
|
||||||
// const [existingResource] = await db
|
// const [existingResource] = await db
|
||||||
@@ -239,7 +279,10 @@ export async function createSiteResource(
|
|||||||
destination,
|
destination,
|
||||||
enabled,
|
enabled,
|
||||||
alias,
|
alias,
|
||||||
aliasAddress
|
aliasAddress,
|
||||||
|
tcpPortRangeString,
|
||||||
|
udpPortRangeString,
|
||||||
|
disableIcmp
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
const deleteSiteResourceParamsSchema = z.strictObject({
|
const deleteSiteResourceParamsSchema = z.strictObject({
|
||||||
siteResourceId: z.string().transform(Number).pipe(z.int().positive()),
|
siteResourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
|
||||||
orgId: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DeleteSiteResourceResponse = {
|
export type DeleteSiteResourceResponse = {
|
||||||
@@ -23,7 +21,7 @@ export type DeleteSiteResourceResponse = {
|
|||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
path: "/site-resource/{siteResourceId}",
|
||||||
description: "Delete a site resource.",
|
description: "Delete a site resource.",
|
||||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
@@ -50,29 +48,13 @@ export async function deleteSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
|
|
||||||
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 not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if site resource exists
|
// Check if site resource exists
|
||||||
const [existingSiteResource] = await db
|
const [existingSiteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(
|
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existingSiteResource) {
|
if (!existingSiteResource) {
|
||||||
@@ -85,19 +67,13 @@ export async function deleteSiteResource(
|
|||||||
// Delete the site resource
|
// Delete the site resource
|
||||||
const [removedSiteResource] = await trx
|
const [removedSiteResource] = await trx
|
||||||
.delete(siteResources)
|
.delete(siteResources)
|
||||||
.where(
|
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, removedSiteResource.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
@@ -113,7 +89,7 @@ export async function deleteSiteResource(
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Deleted site resource ${siteResourceId} for site ${siteId}`
|
`Deleted site resource ${siteResourceId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export type GetSiteResourceResponse = NonNullable<
|
|||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
path: "/site-resource/{siteResourceId}",
|
||||||
description: "Get a specific site resource by siteResourceId.",
|
description: "Get a specific site resource by siteResourceId.",
|
||||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
destination: siteResources.destination,
|
destination: siteResources.destination,
|
||||||
enabled: siteResources.enabled,
|
enabled: siteResources.enabled,
|
||||||
alias: siteResources.alias,
|
alias: siteResources.alias,
|
||||||
|
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||||
|
udpPortRangeString: siteResources.udpPortRangeString,
|
||||||
|
disableIcmp: siteResources.disableIcmp,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
siteNiceId: sites.niceId,
|
||||||
siteAddress: sites.address
|
siteAddress: sites.address
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
newts,
|
newts,
|
||||||
|
orgs,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
sites,
|
sites,
|
||||||
@@ -23,7 +24,9 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
|||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
generateRemoteSubnets,
|
generateRemoteSubnets,
|
||||||
generateSubnetProxyTargets
|
generateSubnetProxyTargets,
|
||||||
|
isIpInCidr,
|
||||||
|
portRangeStringSchema
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import {
|
import {
|
||||||
getClientSiteResourceAccess,
|
getClientSiteResourceAccess,
|
||||||
@@ -31,14 +34,13 @@ import {
|
|||||||
} from "@server/lib/rebuildClientAssociations";
|
} from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
const updateSiteResourceParamsSchema = z.strictObject({
|
const updateSiteResourceParamsSchema = z.strictObject({
|
||||||
siteResourceId: z.string().transform(Number).pipe(z.int().positive()),
|
siteResourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
|
||||||
orgId: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateSiteResourceSchema = z
|
const updateSiteResourceSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
siteId: z.int(),
|
||||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||||
mode: z.enum(["host", "cidr"]).optional(),
|
mode: z.enum(["host", "cidr"]).optional(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||||
@@ -49,13 +51,16 @@ const updateSiteResourceSchema = z
|
|||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
|
/^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/,
|
||||||
"Alias must be a fully qualified domain name (e.g., example.internal)"
|
"Alias must be a fully qualified domain name with optional wildcards (e.g., example.internal, *.example.internal, host-0?.example.internal)"
|
||||||
)
|
)
|
||||||
.nullish(),
|
.nullish(),
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()),
|
||||||
roleIds: z.array(z.int()),
|
roleIds: z.array(z.int()),
|
||||||
clientIds: z.array(z.int())
|
clientIds: z.array(z.int()),
|
||||||
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
|
udpPortRangeString: portRangeStringSchema,
|
||||||
|
disableIcmp: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -74,7 +79,10 @@ const updateSiteResourceSchema = z
|
|||||||
const domainRegex =
|
const domainRegex =
|
||||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||||
const isValidDomain = domainRegex.test(data.destination);
|
const isValidDomain = domainRegex.test(data.destination);
|
||||||
const isValidAlias = data.alias && domainRegex.test(data.alias);
|
const isValidAlias =
|
||||||
|
data.alias !== undefined &&
|
||||||
|
data.alias !== null &&
|
||||||
|
data.alias.trim() !== "";
|
||||||
|
|
||||||
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
|
||||||
}
|
}
|
||||||
@@ -90,8 +98,7 @@ const updateSiteResourceSchema = z
|
|||||||
if (data.mode === "cidr" && data.destination) {
|
if (data.mode === "cidr" && data.destination) {
|
||||||
// Check if it's a valid CIDR (v4 or v6)
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
const isValidCIDR = z
|
const isValidCIDR = z
|
||||||
// .union([z.cidrv4(), z.cidrv6()])
|
.union([z.cidrv4(), z.cidrv6()])
|
||||||
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
|
||||||
.safeParse(data.destination).success;
|
.safeParse(data.destination).success;
|
||||||
return isValidCIDR;
|
return isValidCIDR;
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,7 @@ export type UpdateSiteResourceResponse = SiteResource;
|
|||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
|
path: "/site-resource/{siteResourceId}",
|
||||||
description: "Update a site resource.",
|
description: "Update a site resource.",
|
||||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
@@ -151,22 +158,26 @@ export async function updateSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteResourceId, siteId, orgId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
siteId, // because it can change
|
||||||
mode,
|
mode,
|
||||||
destination,
|
destination,
|
||||||
alias,
|
alias,
|
||||||
enabled,
|
enabled,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
clientIds
|
clientIds,
|
||||||
|
tcpPortRangeString,
|
||||||
|
udpPortRangeString,
|
||||||
|
disableIcmp
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(eq(sites.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
@@ -177,13 +188,7 @@ export async function updateSiteResource(
|
|||||||
const [existingSiteResource] = await db
|
const [existingSiteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(
|
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existingSiteResource) {
|
if (!existingSiteResource) {
|
||||||
@@ -192,6 +197,60 @@ export async function updateSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [org] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, existingSiteResource.orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org.subnet || !org.utilitySubnet) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Organization with ID ${existingSiteResource.orgId} has no subnet or utilitySubnet defined defined`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check if destination is an IP address
|
||||||
|
const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success;
|
||||||
|
if (
|
||||||
|
isIp &&
|
||||||
|
(isIpInCidr(destination!, org.subnet) || isIpInCidr(destination!, org.utilitySubnet))
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"IP can not be in the CIDR range of the organization's subnet or utility subnet"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingSite = site;
|
||||||
|
let siteChanged = false;
|
||||||
|
if (existingSiteResource.siteId !== siteId) {
|
||||||
|
siteChanged = true;
|
||||||
|
// get the existing site
|
||||||
|
[existingSite] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, existingSiteResource.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingSite) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Existing site not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// make sure the alias is unique within the org if provided
|
// make sure the alias is unique within the org if provided
|
||||||
if (alias) {
|
if (alias) {
|
||||||
const [conflict] = await db
|
const [conflict] = await db
|
||||||
@@ -199,7 +258,7 @@ export async function updateSiteResource(
|
|||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, existingSiteResource.orgId),
|
||||||
eq(siteResources.alias, alias.trim()),
|
eq(siteResources.alias, alias.trim()),
|
||||||
ne(siteResources.siteResourceId, siteResourceId) // exclude self
|
ne(siteResources.siteResourceId, siteResourceId) // exclude self
|
||||||
)
|
)
|
||||||
@@ -218,97 +277,220 @@ export async function updateSiteResource(
|
|||||||
|
|
||||||
let updatedSiteResource: SiteResource | undefined;
|
let updatedSiteResource: SiteResource | undefined;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Update the site resource
|
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
|
||||||
[updatedSiteResource] = await trx
|
if (siteChanged) {
|
||||||
.update(siteResources)
|
// delete the existing site resource
|
||||||
.set({
|
|
||||||
name: name,
|
|
||||||
mode: mode,
|
|
||||||
destination: destination,
|
|
||||||
enabled: enabled,
|
|
||||||
alias: alias && alias.trim() ? alias : null
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(clientSiteResources)
|
|
||||||
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
|
||||||
|
|
||||||
if (clientIds.length > 0) {
|
|
||||||
await trx.insert(clientSiteResources).values(
|
|
||||||
clientIds.map((clientId) => ({
|
|
||||||
clientId,
|
|
||||||
siteResourceId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(userSiteResources)
|
|
||||||
.where(eq(userSiteResources.siteResourceId, siteResourceId));
|
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
|
||||||
await trx
|
await trx
|
||||||
.insert(userSiteResources)
|
.delete(siteResources)
|
||||||
.values(
|
.where(
|
||||||
userIds.map((userId) => ({ userId, siteResourceId }))
|
and(eq(siteResources.siteResourceId, siteResourceId))
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Get all admin role IDs for this org to exclude from deletion
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
const adminRoles = await trx
|
existingSiteResource,
|
||||||
.select()
|
trx
|
||||||
.from(roles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roles.isAdmin, true),
|
|
||||||
eq(roles.orgId, updatedSiteResource.orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
|
||||||
|
|
||||||
if (adminRoleIds.length > 0) {
|
// create the new site resource from the removed one - the ID should stay the same
|
||||||
await trx.delete(roleSiteResources).where(
|
const [insertedSiteResource] = await trx
|
||||||
and(
|
.insert(siteResources)
|
||||||
eq(roleSiteResources.siteResourceId, siteResourceId),
|
.values({
|
||||||
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
...existingSiteResource,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// wait some time to allow for messages to be handled
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
|
||||||
|
[updatedSiteResource] = await trx
|
||||||
|
.update(siteResources)
|
||||||
|
.set({
|
||||||
|
name: name,
|
||||||
|
siteId: siteId,
|
||||||
|
mode: mode,
|
||||||
|
destination: destination,
|
||||||
|
enabled: enabled,
|
||||||
|
alias: alias && alias.trim() ? alias : null,
|
||||||
|
tcpPortRangeString: tcpPortRangeString,
|
||||||
|
udpPortRangeString: udpPortRangeString,
|
||||||
|
disableIcmp: disableIcmp
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
insertedSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedSiteResource) {
|
||||||
|
throw new Error(
|
||||||
|
"Failed to create updated site resource after site change"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roles.isAdmin, true),
|
||||||
|
eq(roles.orgId, updatedSiteResource.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Admin role not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleSiteResources).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
siteResourceId: updatedSiteResource.siteResourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
await trx.insert(roleSiteResources).values(
|
||||||
|
roleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId: updatedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await trx.insert(userSiteResources).values(
|
||||||
|
userIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId: updatedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId: updatedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
updatedSiteResource,
|
||||||
|
trx
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await trx
|
// Update the site resource
|
||||||
.delete(roleSiteResources)
|
[updatedSiteResource] = await trx
|
||||||
|
.update(siteResources)
|
||||||
|
.set({
|
||||||
|
name: name,
|
||||||
|
siteId: siteId,
|
||||||
|
mode: mode,
|
||||||
|
destination: destination,
|
||||||
|
enabled: enabled,
|
||||||
|
alias: alias && alias.trim() ? alias : null,
|
||||||
|
tcpPortRangeString: tcpPortRangeString,
|
||||||
|
udpPortRangeString: udpPortRangeString,
|
||||||
|
disableIcmp: disableIcmp
|
||||||
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(roleSiteResources.siteResourceId, siteResourceId)
|
and(eq(siteResources.siteResourceId, siteResourceId))
|
||||||
);
|
)
|
||||||
}
|
.returning();
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
if (roleIds.length > 0) {
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.delete(clientSiteResources)
|
||||||
.values(
|
.where(
|
||||||
roleIds.map((roleId) => ({ roleId, siteResourceId }))
|
eq(clientSiteResources.siteResourceId, siteResourceId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (clientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.delete(userSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(userSiteResources.siteResourceId, siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await trx.insert(userSiteResources).values(
|
||||||
|
userIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all admin role IDs for this org to exclude from deletion
|
||||||
|
const adminRoles = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roles.isAdmin, true),
|
||||||
|
eq(roles.orgId, updatedSiteResource.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
|
if (adminRoleIds.length > 0) {
|
||||||
|
await trx.delete(roleSiteResources).where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
siteResourceId
|
||||||
|
),
|
||||||
|
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(roleSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(roleSiteResources.siteResourceId, siteResourceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
await trx.insert(roleSiteResources).values(
|
||||||
|
roleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Updated site resource ${siteResourceId} for site ${siteId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleMessagingForUpdatedSiteResource(
|
||||||
|
existingSiteResource,
|
||||||
|
updatedSiteResource,
|
||||||
|
{ siteId: site.siteId, orgId: site.orgId },
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Updated site resource ${siteResourceId} for site ${siteId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await handleMessagingForUpdatedSiteResource(
|
|
||||||
existingSiteResource,
|
|
||||||
updatedSiteResource!,
|
|
||||||
{ siteId: site.siteId, orgId: site.orgId },
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
@@ -335,6 +517,10 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
site: { siteId: number; orgId: string },
|
site: { siteId: number; orgId: string },
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
logger.debug("handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource);
|
||||||
|
logger.debug("handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", updatedSiteResource);
|
||||||
|
|
||||||
const { mergedAllClients } =
|
const { mergedAllClients } =
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
|
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below
|
||||||
@@ -348,10 +534,18 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
const aliasChanged =
|
const aliasChanged =
|
||||||
existingSiteResource &&
|
existingSiteResource &&
|
||||||
existingSiteResource.alias !== updatedSiteResource.alias;
|
existingSiteResource.alias !== updatedSiteResource.alias;
|
||||||
|
const portRangesChanged =
|
||||||
|
existingSiteResource &&
|
||||||
|
(existingSiteResource.tcpPortRangeString !==
|
||||||
|
updatedSiteResource.tcpPortRangeString ||
|
||||||
|
existingSiteResource.udpPortRangeString !==
|
||||||
|
updatedSiteResource.udpPortRangeString ||
|
||||||
|
existingSiteResource.disableIcmp !==
|
||||||
|
updatedSiteResource.disableIcmp);
|
||||||
|
|
||||||
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
|
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
|
||||||
|
|
||||||
if (destinationChanged || aliasChanged) {
|
if (destinationChanged || aliasChanged || portRangesChanged) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
@@ -365,7 +559,7 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only update targets on newt if destination changed
|
// Only update targets on newt if destination changed
|
||||||
if (destinationChanged) {
|
if (destinationChanged || portRangesChanged) {
|
||||||
const oldTargets = generateSubnetProxyTargets(
|
const oldTargets = generateSubnetProxyTargets(
|
||||||
existingSiteResource,
|
existingSiteResource,
|
||||||
mergedAllClients
|
mergedAllClients
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
const removeInvitationParamsSchema = z.strictObject({
|
const removeInvitationParamsSchema = z.strictObject({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
inviteId: z.string()
|
inviteId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/org/{orgId}/invitations/{inviteId}",
|
||||||
|
description: "Remove an open invitation from an organization",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: removeInvitationParamsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
export async function removeInvitation(
|
export async function removeInvitation(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ function generateToken(): string {
|
|||||||
return generateRandomString(random, alphabet, 32);
|
return generateRandomString(random, alphabet, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateToken(token: string): boolean {
|
||||||
|
const tokenRegex = /^[a-z0-9]{32}$/;
|
||||||
|
return tokenRegex.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
function generateId(length: number): string {
|
function generateId(length: number): string {
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
return generateRandomString(random, alphabet, length);
|
return generateRandomString(random, alphabet, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showSetupToken(token: string, source: string): void {
|
||||||
|
console.log(`=== SETUP TOKEN ${source} ===`);
|
||||||
|
console.log("Token:", token);
|
||||||
|
console.log("Use this token on the initial setup page");
|
||||||
|
console.log("================================");
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureSetupToken() {
|
export async function ensureSetupToken() {
|
||||||
try {
|
try {
|
||||||
// Check if a server admin already exists
|
// Check if a server admin already exists
|
||||||
@@ -38,17 +50,48 @@ export async function ensureSetupToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if a setup token already exists
|
// Check if a setup token already exists
|
||||||
const existingTokens = await db
|
const [existingToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(setupTokens)
|
.from(setupTokens)
|
||||||
.where(eq(setupTokens.used, false));
|
.where(eq(setupTokens.used, false));
|
||||||
|
|
||||||
|
const envSetupToken = process.env.PANGOLIN_SETUP_TOKEN;
|
||||||
|
console.debug("PANGOLIN_SETUP_TOKEN:", envSetupToken);
|
||||||
|
if (envSetupToken) {
|
||||||
|
if (!validateToken(envSetupToken)) {
|
||||||
|
throw new Error(
|
||||||
|
"invalid token format for PANGOLIN_SETUP_TOKEN"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingToken?.token !== envSetupToken) {
|
||||||
|
console.warn(
|
||||||
|
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(setupTokens)
|
||||||
|
.set({ token: envSetupToken })
|
||||||
|
.where(eq(setupTokens.tokenId, existingToken.tokenId));
|
||||||
|
} else {
|
||||||
|
const tokenId = generateId(15);
|
||||||
|
|
||||||
|
await db.insert(setupTokens).values({
|
||||||
|
tokenId: tokenId,
|
||||||
|
token: envSetupToken,
|
||||||
|
used: false,
|
||||||
|
dateCreated: moment().toISOString(),
|
||||||
|
dateUsed: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showSetupToken(envSetupToken, "FROM ENVIRONMENT");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If unused token exists, display it instead of creating a new one
|
// If unused token exists, display it instead of creating a new one
|
||||||
if (existingTokens.length > 0) {
|
if (existingToken) {
|
||||||
console.log("=== SETUP TOKEN EXISTS ===");
|
showSetupToken(existingToken.token, "EXISTS");
|
||||||
console.log("Token:", existingTokens[0].token);
|
|
||||||
console.log("Use this token on the initial setup page");
|
|
||||||
console.log("================================");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +107,7 @@ export async function ensureSetupToken() {
|
|||||||
dateUsed: null
|
dateUsed: null
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("=== SETUP TOKEN GENERATED ===");
|
showSetupToken(token, "GENERATED");
|
||||||
console.log("Token:", token);
|
|
||||||
console.log("Use this token on the initial setup page");
|
|
||||||
console.log("================================");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to ensure setup token:", error);
|
console.error("Failed to ensure setup token:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
|
||||||
type BillingSettingsProps = {
|
type BillingSettingsProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +18,7 @@ export default async function BillingSettingsPage({
|
|||||||
}: BillingSettingsProps) {
|
}: BillingSettingsProps) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession();
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -32,13 +26,7 @@ export default async function BillingSettingsPage({
|
|||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(async () =>
|
const res = await getCachedOrgUser(orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrgUser();
|
|
||||||
orgUser = res.data.data;
|
orgUser = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -46,13 +34,7 @@ export default async function BillingSettingsPage({
|
|||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(async () =>
|
const res = await getCachedOrg(orgId);
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
redirect(`/${params.orgId}/settings/idp`);
|
redirect(`/${params.orgId}/settings/idp`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: HorizontalTabs = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
|
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
|
||||||
|
|||||||
@@ -303,6 +303,24 @@ export default function Page() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("idpType")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StrategySelect
|
||||||
|
options={providerTypes}
|
||||||
|
defaultValue={form.getValues("type")}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleProviderChange(
|
||||||
|
value as "oidc" | "google" | "azure"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
cols={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -334,29 +352,6 @@ export default function Page() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("idpType")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("idpTypeDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<StrategySelect
|
|
||||||
options={providerTypes}
|
|
||||||
defaultValue={form.getValues("type")}
|
|
||||||
onChange={(value) => {
|
|
||||||
handleProviderChange(
|
|
||||||
value as "oidc" | "google" | "azure"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
cols={3}
|
|
||||||
/>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
{/* Auto Provision Settings */}
|
{/* Auto Provision Settings */}
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
@@ -705,29 +700,6 @@ export default function Page() {
|
|||||||
id="create-idp-form"
|
id="create-idp-form"
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
>
|
>
|
||||||
<Alert variant="neutral">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t("idpJmespathAbout")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t(
|
|
||||||
"idpJmespathAboutDescription"
|
|
||||||
)}{" "}
|
|
||||||
<a
|
|
||||||
href="https://jmespath.org"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline inline-flex items-center"
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"idpJmespathAboutDescriptionLink"
|
|
||||||
)}{" "}
|
|
||||||
<ExternalLink className="ml-1 h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="identifierPath"
|
name="identifierPath"
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
export default function CredentialsPage() {
|
export default function CredentialsPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -131,19 +131,19 @@ export default function CredentialsPage() {
|
|||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t("generatedcredentials")}
|
{t("credentials")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("regenerateCredentials")}
|
{t("remoteNodeCredentialsDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("endpoint") || "Endpoint"}
|
{t("endpoint")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
@@ -153,8 +153,7 @@ export default function CredentialsPage() {
|
|||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("remoteExitNodeId") ||
|
{t("remoteExitNodeId")}
|
||||||
"Remote Exit Node ID"}
|
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{displayRemoteExitNodeId ? (
|
{displayRemoteExitNodeId ? (
|
||||||
@@ -168,7 +167,7 @@ export default function CredentialsPage() {
|
|||||||
</InfoSection>
|
</InfoSection>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("secretKey") || "Secret Key"}
|
{t("remoteExitNodeSecretKey")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{displaySecret ? (
|
{displaySecret ? (
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title={`Remote Exit Node ${remoteExitNode?.name || "Unknown"}`}
|
title={`Remote Node ${remoteExitNode?.name || "Unknown"}`}
|
||||||
description="Manage your remote exit node settings and configuration"
|
description="Manage your remote exit node settings and configuration"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -319,19 +319,6 @@ export default function CreateRemoteExitNodePage() {
|
|||||||
id: "${defaults?.remoteExitNodeId}"
|
id: "${defaults?.remoteExitNodeId}"
|
||||||
secret: "${defaults?.secret}"`}
|
secret: "${defaults?.secret}"`}
|
||||||
/>
|
/>
|
||||||
<Alert variant="neutral" className="mt-4">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t(
|
|
||||||
"remoteExitNodeCreate.generate.saveCredentialsTitle"
|
|
||||||
)}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t(
|
|
||||||
"remoteExitNodeCreate.generate.saveCredentialsDescription"
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import ExitNodesTable, { RemoteExitNodeRow } from "./ExitNodesTable";
|
import ExitNodesTable, {
|
||||||
|
RemoteExitNodeRow
|
||||||
|
} from "@app/components/ExitNodesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
@@ -32,6 +31,7 @@ import {
|
|||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
export default function CredentialsPage() {
|
export default function CredentialsPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -127,7 +127,7 @@ export default function CredentialsPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
|
|||||||
@@ -523,18 +523,6 @@ export default function Page() {
|
|||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
|
|
||||||
<Alert variant="neutral" className="">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t("clientCredentialsSave")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t(
|
|
||||||
"clientCredentialsSaveDescription"
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ClientRow } from "@app/components/MachineClientsTable";
|
import type { ClientRow } from "@app/components/MachineClientsTable";
|
||||||
import MachineClientsTable from "@app/components/MachineClientsTable";
|
import MachineClientsTable from "@app/components/MachineClientsTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import MachineClientsBanner from "@app/components/MachineClientsBanner";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
@@ -71,6 +72,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
description={t("manageMachineClientsDescription")}
|
description={t("manageMachineClientsDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MachineClientsBanner orgId={params.orgId} />
|
||||||
|
|
||||||
<MachineClientsTable
|
<MachineClientsTable
|
||||||
machineClients={machineClientRows}
|
machineClients={machineClientRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
|
|||||||
56
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal file
56
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm";
|
||||||
|
import AuthPageSettings from "@app/components/private/AuthPageSettings";
|
||||||
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { getCachedSubscription } from "@app/lib/api/getCachedSubscription";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import type { GetOrgTierResponse } from "@server/routers/billing/types";
|
||||||
|
import {
|
||||||
|
GetLoginPageBrandingResponse,
|
||||||
|
GetLoginPageResponse
|
||||||
|
} from "@server/routers/loginPage/types";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
export interface AuthPageProps {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AuthPage(props: AuthPageProps) {
|
||||||
|
const orgId = (await props.params).orgId;
|
||||||
|
let subscriptionStatus: GetOrgTierResponse | null = null;
|
||||||
|
try {
|
||||||
|
const subRes = await getCachedSubscription(orgId);
|
||||||
|
subscriptionStatus = subRes.data.data;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let loginPage: GetLoginPageResponse | null = null;
|
||||||
|
try {
|
||||||
|
if (build === "saas") {
|
||||||
|
const res = await internal.get<AxiosResponse<GetLoginPageResponse>>(
|
||||||
|
`/org/${orgId}/login-page`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
loginPage = res.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
let loginPageBranding: GetLoginPageBrandingResponse | null = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<GetLoginPageBrandingResponse>
|
||||||
|
>(`/org/${orgId}/login-page-branding`, await authCookieHeader());
|
||||||
|
if (res.status === 200) {
|
||||||
|
loginPageBranding = res.data.data;
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
{build === "saas" && <AuthPageSettings loginPage={loginPage} />}
|
||||||
|
<AuthPageBrandingForm orgId={orgId} branding={loginPageBranding} />
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import OrgInfoCard from "@app/components/OrgInfoCard";
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
type GeneralSettingsProps = {
|
type GeneralSettingsProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +22,7 @@ export default async function GeneralSettingsPage({
|
|||||||
}: GeneralSettingsProps) {
|
}: GeneralSettingsProps) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession();
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -32,13 +30,7 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(async () =>
|
const res = await getCachedOrgUser(orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrgUser();
|
|
||||||
orgUser = res.data.data;
|
orgUser = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -46,13 +38,7 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(async () =>
|
const res = await getCachedOrg(orgId);
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -60,12 +46,19 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const navItems = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/{orgId}/settings/general`
|
href: `/{orgId}/settings/general`,
|
||||||
|
exact: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
if (build !== "oss") {
|
||||||
|
navItems.push({
|
||||||
|
title: t("authPage"),
|
||||||
|
href: `/{orgId}/settings/general/auth-page`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -76,7 +69,10 @@ export default async function GeneralSettingsPage({
|
|||||||
description={t("orgSettingsDescription")}
|
description={t("orgSettingsDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
<div className="space-y-6">
|
||||||
|
<OrgInfoCard />
|
||||||
|
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||||
|
</div>
|
||||||
</OrgUserProvider>
|
</OrgUserProvider>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
|
import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
|
||||||
import ClientResourcesTable from "@app/components/ClientResourcesTable";
|
import ClientResourcesTable from "@app/components/ClientResourcesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
@@ -67,7 +68,10 @@ export default async function ClientResourcesPage(
|
|||||||
// destinationPort: siteResource.destinationPort,
|
// destinationPort: siteResource.destinationPort,
|
||||||
alias: siteResource.alias || null,
|
alias: siteResource.alias || null,
|
||||||
siteNiceId: siteResource.siteNiceId,
|
siteNiceId: siteResource.siteNiceId,
|
||||||
niceId: siteResource.niceId
|
niceId: siteResource.niceId,
|
||||||
|
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||||
|
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||||
|
disableIcmp: siteResource.disableIcmp || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -78,6 +82,8 @@ export default async function ClientResourcesPage(
|
|||||||
description={t("clientResourceDescription")}
|
description={t("clientResourceDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PrivateResourcesBanner orgId={params.orgId} />
|
||||||
|
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<ClientResourcesTable
|
<ClientResourcesTable
|
||||||
internalResources={internalResourceRows}
|
internalResources={internalResourceRows}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import {
|
import {
|
||||||
GetResourceWhitelistResponse,
|
SettingsContainer,
|
||||||
ListResourceRolesResponse,
|
SettingsSection,
|
||||||
ListResourceUsersResponse
|
SettingsSectionBody,
|
||||||
} from "@server/routers/resource";
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { z } from "zod";
|
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -25,32 +26,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
|
||||||
import { Binary, Key, Bot } from "lucide-react";
|
|
||||||
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
|
||||||
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
|
|
||||||
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionTitle,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionFooter,
|
|
||||||
SettingsSectionForm
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { InfoIcon } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -58,10 +34,32 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
import { build } from "@server/build";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
||||||
|
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
useActionState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition
|
||||||
|
} from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const UsersRolesFormSchema = z.object({
|
const UsersRolesFormSchema = z.object({
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
@@ -100,14 +98,83 @@ export default function ResourceAuthenticationPage() {
|
|||||||
|
|
||||||
const subscription = useSubscriptionStatusContext();
|
const subscription = useSubscriptionStatusContext();
|
||||||
|
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const queryClient = useQueryClient();
|
||||||
|
const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } =
|
||||||
|
useQuery(
|
||||||
|
resourceQueries.resourceRoles({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { data: resourceUsers = [], isLoading: isLoadingResourceUsers } =
|
||||||
|
useQuery(
|
||||||
|
resourceQueries.resourceUsers({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
|
const { data: whitelist = [], isLoading: isLoadingWhiteList } = useQuery(
|
||||||
[]
|
resourceQueries.resourceWhitelist({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>(
|
|
||||||
[]
|
const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery(
|
||||||
|
orgQueries.roles({
|
||||||
|
orgId: org.org.orgId
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery(
|
||||||
|
orgQueries.users({
|
||||||
|
orgId: org.org.orgId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
|
||||||
|
orgQueries.identityProviders({
|
||||||
|
orgId: org.org.orgId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageLoading =
|
||||||
|
isLoadingOrgRoles ||
|
||||||
|
isLoadingOrgUsers ||
|
||||||
|
isLoadingResourceRoles ||
|
||||||
|
isLoadingResourceUsers ||
|
||||||
|
isLoadingWhiteList ||
|
||||||
|
isLoadingOrgIdps;
|
||||||
|
|
||||||
|
const allRoles = useMemo(() => {
|
||||||
|
return orgRoles
|
||||||
|
.map((role) => ({
|
||||||
|
id: role.roleId.toString(),
|
||||||
|
text: role.name
|
||||||
|
}))
|
||||||
|
.filter((role) => role.text !== "Admin");
|
||||||
|
}, [orgRoles]);
|
||||||
|
|
||||||
|
const allUsers = useMemo(() => {
|
||||||
|
return orgUsers.map((user) => ({
|
||||||
|
id: user.id.toString(),
|
||||||
|
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||||
|
}));
|
||||||
|
}, [orgUsers]);
|
||||||
|
|
||||||
|
const allIdps = useMemo(() => {
|
||||||
|
if (build === "saas") {
|
||||||
|
if (subscription?.subscribed) {
|
||||||
|
return orgIdps.map((idp) => ({
|
||||||
|
id: idp.idpId,
|
||||||
|
text: idp.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return orgIdps.map((idp) => ({
|
||||||
|
id: idp.idpId,
|
||||||
|
text: idp.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [orgIdps]);
|
||||||
|
|
||||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -115,15 +182,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
|
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
|
||||||
// const [blockAccess, setBlockAccess] = useState(resource.blockAccess);
|
|
||||||
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
|
||||||
resource.emailWhitelistEnabled
|
|
||||||
);
|
|
||||||
|
|
||||||
const [autoLoginEnabled, setAutoLoginEnabled] = useState(
|
const [autoLoginEnabled, setAutoLoginEnabled] = useState(
|
||||||
resource.skipToIdpId !== null && resource.skipToIdpId !== undefined
|
resource.skipToIdpId !== null && resource.skipToIdpId !== undefined
|
||||||
@@ -131,10 +190,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
||||||
resource.skipToIdpId || null
|
resource.skipToIdpId || null
|
||||||
);
|
);
|
||||||
const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]);
|
|
||||||
|
|
||||||
const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false);
|
|
||||||
const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false);
|
|
||||||
|
|
||||||
const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] =
|
const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -159,167 +214,61 @@ export default function ResourceAuthenticationPage() {
|
|||||||
defaultValues: { emails: [] }
|
defaultValues: { emails: [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
if (pageLoading || hasInitializedRef.current) return;
|
||||||
try {
|
|
||||||
const [
|
|
||||||
rolesResponse,
|
|
||||||
resourceRolesResponse,
|
|
||||||
usersResponse,
|
|
||||||
resourceUsersResponse,
|
|
||||||
whitelist,
|
|
||||||
idpsResponse
|
|
||||||
] = await Promise.all([
|
|
||||||
api.get<AxiosResponse<ListRolesResponse>>(
|
|
||||||
`/org/${org?.org.orgId}/roles`
|
|
||||||
),
|
|
||||||
api.get<AxiosResponse<ListResourceRolesResponse>>(
|
|
||||||
`/resource/${resource.resourceId}/roles`
|
|
||||||
),
|
|
||||||
api.get<AxiosResponse<ListUsersResponse>>(
|
|
||||||
`/org/${org?.org.orgId}/users`
|
|
||||||
),
|
|
||||||
api.get<AxiosResponse<ListResourceUsersResponse>>(
|
|
||||||
`/resource/${resource.resourceId}/users`
|
|
||||||
),
|
|
||||||
api.get<AxiosResponse<GetResourceWhitelistResponse>>(
|
|
||||||
`/resource/${resource.resourceId}/whitelist`
|
|
||||||
),
|
|
||||||
api.get<
|
|
||||||
AxiosResponse<{
|
|
||||||
idps: { idpId: number; name: string }[];
|
|
||||||
}>
|
|
||||||
>(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp")
|
|
||||||
]);
|
|
||||||
|
|
||||||
setAllRoles(
|
usersRolesForm.setValue(
|
||||||
rolesResponse.data.data.roles
|
"roles",
|
||||||
.map((role) => ({
|
resourceRoles
|
||||||
id: role.roleId.toString(),
|
.map((i) => ({
|
||||||
text: role.name
|
id: i.roleId.toString(),
|
||||||
}))
|
text: i.name
|
||||||
.filter((role) => role.text !== "Admin")
|
}))
|
||||||
);
|
.filter((role) => role.text !== "Admin")
|
||||||
|
);
|
||||||
|
usersRolesForm.setValue(
|
||||||
|
"users",
|
||||||
|
resourceUsers.map((i) => ({
|
||||||
|
id: i.userId.toString(),
|
||||||
|
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
usersRolesForm.setValue(
|
whitelistForm.setValue(
|
||||||
"roles",
|
"emails",
|
||||||
resourceRolesResponse.data.data.roles
|
whitelist.map((w) => ({
|
||||||
.map((i) => ({
|
id: w.email,
|
||||||
id: i.roleId.toString(),
|
text: w.email
|
||||||
text: i.name
|
}))
|
||||||
}))
|
);
|
||||||
.filter((role) => role.text !== "Admin")
|
if (autoLoginEnabled && !selectedIdpId && orgIdps.length > 0) {
|
||||||
);
|
setSelectedIdpId(orgIdps[0].idpId);
|
||||||
|
|
||||||
setAllUsers(
|
|
||||||
usersResponse.data.data.users.map((user) => ({
|
|
||||||
id: user.id.toString(),
|
|
||||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
usersRolesForm.setValue(
|
|
||||||
"users",
|
|
||||||
resourceUsersResponse.data.data.users.map((i) => ({
|
|
||||||
id: i.userId.toString(),
|
|
||||||
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
whitelistForm.setValue(
|
|
||||||
"emails",
|
|
||||||
whitelist.data.data.whitelist.map((w) => ({
|
|
||||||
id: w.email,
|
|
||||||
text: w.email
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
if (subscription?.subscribed) {
|
|
||||||
setAllIdps(
|
|
||||||
idpsResponse.data.data.idps.map((idp) => ({
|
|
||||||
id: idp.idpId,
|
|
||||||
text: idp.name
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAllIdps(
|
|
||||||
idpsResponse.data.data.idps.map((idp) => ({
|
|
||||||
id: idp.idpId,
|
|
||||||
text: idp.name
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
autoLoginEnabled &&
|
|
||||||
!selectedIdpId &&
|
|
||||||
idpsResponse.data.data.idps.length > 0
|
|
||||||
) {
|
|
||||||
setSelectedIdpId(idpsResponse.data.data.idps[0].idpId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPageLoading(false);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("resourceErrorAuthFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("resourceErrorAuthFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function saveWhitelist() {
|
|
||||||
setLoadingSaveWhitelist(true);
|
|
||||||
try {
|
|
||||||
await api.post(`/resource/${resource.resourceId}`, {
|
|
||||||
emailWhitelistEnabled: whitelistEnabled
|
|
||||||
});
|
|
||||||
|
|
||||||
if (whitelistEnabled) {
|
|
||||||
await api.post(`/resource/${resource.resourceId}/whitelist`, {
|
|
||||||
emails: whitelistForm.getValues().emails.map((i) => i.text)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateResource({
|
|
||||||
emailWhitelistEnabled: whitelistEnabled
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("resourceWhitelistSave"),
|
|
||||||
description: t("resourceWhitelistSaveDescription")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("resourceErrorWhitelistSave"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("resourceErrorWhitelistSaveDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoadingSaveWhitelist(false);
|
|
||||||
}
|
}
|
||||||
}
|
hasInitializedRef.current = true;
|
||||||
|
}, [
|
||||||
|
pageLoading,
|
||||||
|
resourceRoles,
|
||||||
|
resourceUsers,
|
||||||
|
whitelist,
|
||||||
|
autoLoginEnabled,
|
||||||
|
selectedIdpId,
|
||||||
|
orgIdps
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [, submitUserRolesForm, loadingSaveUsersRoles] = useActionState(
|
||||||
|
onSubmitUsersRoles,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onSubmitUsersRoles() {
|
||||||
|
const isValid = usersRolesForm.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = usersRolesForm.getValues();
|
||||||
|
|
||||||
async function onSubmitUsersRoles(
|
|
||||||
data: z.infer<typeof UsersRolesFormSchema>
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
setLoadingSaveUsersRoles(true);
|
|
||||||
|
|
||||||
// Validate that an IDP is selected if auto login is enabled
|
// Validate that an IDP is selected if auto login is enabled
|
||||||
if (autoLoginEnabled && !selectedIdpId) {
|
if (autoLoginEnabled && !selectedIdpId) {
|
||||||
toast({
|
toast({
|
||||||
@@ -358,6 +307,17 @@ export default function ResourceAuthenticationPage() {
|
|||||||
title: t("resourceAuthSettingsSave"),
|
title: t("resourceAuthSettingsSave"),
|
||||||
description: t("resourceAuthSettingsSaveDescription")
|
description: t("resourceAuthSettingsSaveDescription")
|
||||||
});
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
predicate(query) {
|
||||||
|
const resourceKey = resourceQueries.resourceClients({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
}).queryKey;
|
||||||
|
return (
|
||||||
|
query.queryKey[0] === resourceKey[0] &&
|
||||||
|
query.queryKey[1] === resourceKey[1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -369,8 +329,6 @@ export default function ResourceAuthenticationPage() {
|
|||||||
t("resourceErrorUsersRolesSaveDescription")
|
t("resourceErrorUsersRolesSaveDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setLoadingSaveUsersRoles(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,9 +492,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
|
|
||||||
<Form {...usersRolesForm}>
|
<Form {...usersRolesForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={usersRolesForm.handleSubmit(
|
action={submitUserRolesForm}
|
||||||
onSubmitUsersRoles
|
|
||||||
)}
|
|
||||||
id="users-roles-form"
|
id="users-roles-form"
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
@@ -661,7 +617,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{ssoEnabled && allIdps.length > 0 && (
|
{ssoEnabled && allIdps.length > 0 && (
|
||||||
<div className="mt-8">
|
<>
|
||||||
<div className="space-y-2 mb-3">
|
<div className="space-y-2 mb-3">
|
||||||
<CheckboxWithLabel
|
<CheckboxWithLabel
|
||||||
label={t(
|
label={t(
|
||||||
@@ -698,7 +654,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
{autoLoginEnabled && (
|
{autoLoginEnabled && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-sm font-medium">
|
||||||
{t("selectIdp")}
|
{t("defaultIdentityProvider")}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
@@ -714,7 +670,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full mt-1">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"selectIdpPlaceholder"
|
"selectIdpPlaceholder"
|
||||||
@@ -740,7 +696,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -772,7 +728,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
{/* Password Protection */}
|
{/* Password Protection */}
|
||||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${!authInfo.password ? "text-muted-foreground" : "text-green-500"} text-sm space-x-2`}
|
className={`flex items-center ${!authInfo.password ? "" : "text-green-500"} text-sm space-x-2`}
|
||||||
>
|
>
|
||||||
<Key size="14" />
|
<Key size="14" />
|
||||||
<span>
|
<span>
|
||||||
@@ -802,7 +758,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
{/* PIN Code Protection */}
|
{/* PIN Code Protection */}
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${!authInfo.pincode ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
|
className={`flex items-center ${!authInfo.pincode ? "" : "text-green-500"} space-x-2 text-sm`}
|
||||||
>
|
>
|
||||||
<Binary size="14" />
|
<Binary size="14" />
|
||||||
<span>
|
<span>
|
||||||
@@ -832,7 +788,7 @@ export default function ResourceAuthenticationPage() {
|
|||||||
{/* Header Authentication Protection */}
|
{/* Header Authentication Protection */}
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center ${!authInfo.headerAuth ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
|
className={`flex items-center ${!authInfo.headerAuth ? "" : "text-green-500"} space-x-2 text-sm`}
|
||||||
>
|
>
|
||||||
<Bot size="14" />
|
<Bot size="14" />
|
||||||
<span>
|
<span>
|
||||||
@@ -864,136 +820,202 @@ export default function ResourceAuthenticationPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection>
|
<OneTimePasswordFormSection
|
||||||
<SettingsSectionHeader>
|
resource={resource}
|
||||||
<SettingsSectionTitle>
|
updateResource={updateResource}
|
||||||
{t("otpEmailTitle")}
|
/>
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("otpEmailTitleDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
{!env.email.emailEnabled && (
|
|
||||||
<Alert variant="neutral" className="mb-4">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t("otpEmailSmtpRequired")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("otpEmailSmtpRequiredDescription")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<SwitchInput
|
|
||||||
id="whitelist-toggle"
|
|
||||||
label={t("otpEmailWhitelist")}
|
|
||||||
defaultChecked={resource.emailWhitelistEnabled}
|
|
||||||
onCheckedChange={setWhitelistEnabled}
|
|
||||||
disabled={!env.email.emailEnabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{whitelistEnabled && env.email.emailEnabled && (
|
|
||||||
<Form {...whitelistForm}>
|
|
||||||
<form id="whitelist-form">
|
|
||||||
<FormField
|
|
||||||
control={whitelistForm.control}
|
|
||||||
name="emails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<InfoPopup
|
|
||||||
text={t(
|
|
||||||
"otpEmailWhitelistList"
|
|
||||||
)}
|
|
||||||
info={t(
|
|
||||||
"otpEmailWhitelistListDescription"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={
|
|
||||||
activeEmailTagIndex
|
|
||||||
}
|
|
||||||
size={"sm"}
|
|
||||||
validateTag={(
|
|
||||||
tag
|
|
||||||
) => {
|
|
||||||
return z
|
|
||||||
.email()
|
|
||||||
.or(
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.regex(
|
|
||||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
t(
|
|
||||||
"otpEmailErrorInvalid"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.safeParse(
|
|
||||||
tag
|
|
||||||
).success;
|
|
||||||
}}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveEmailTagIndex
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"otpEmailEnter"
|
|
||||||
)}
|
|
||||||
tags={
|
|
||||||
whitelistForm.getValues()
|
|
||||||
.emails
|
|
||||||
}
|
|
||||||
setTags={(
|
|
||||||
newRoles
|
|
||||||
) => {
|
|
||||||
whitelistForm.setValue(
|
|
||||||
"emails",
|
|
||||||
newRoles as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
allowDuplicates={
|
|
||||||
false
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t(
|
|
||||||
"otpEmailEnterDescription"
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
<SettingsSectionFooter>
|
|
||||||
<Button
|
|
||||||
onClick={saveWhitelist}
|
|
||||||
form="whitelist-form"
|
|
||||||
loading={loadingSaveWhitelist}
|
|
||||||
disabled={loadingSaveWhitelist}
|
|
||||||
>
|
|
||||||
{t("otpEmailWhitelistSave")}
|
|
||||||
</Button>
|
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OneTimePasswordFormSectionProps = Pick<
|
||||||
|
ResourceContextType,
|
||||||
|
"resource" | "updateResource"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function OneTimePasswordFormSection({
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: OneTimePasswordFormSectionProps) {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
||||||
|
resource.emailWhitelistEnabled
|
||||||
|
);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [loadingSaveWhitelist, startTransition] = useTransition();
|
||||||
|
const whitelistForm = useForm({
|
||||||
|
resolver: zodResolver(whitelistSchema),
|
||||||
|
defaultValues: { emails: [] }
|
||||||
|
});
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
async function saveWhitelist() {
|
||||||
|
try {
|
||||||
|
await api.post(`/resource/${resource.resourceId}`, {
|
||||||
|
emailWhitelistEnabled: whitelistEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
if (whitelistEnabled) {
|
||||||
|
await api.post(`/resource/${resource.resourceId}/whitelist`, {
|
||||||
|
emails: whitelistForm.getValues().emails.map((i) => i.text)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResource({
|
||||||
|
emailWhitelistEnabled: whitelistEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("resourceWhitelistSave"),
|
||||||
|
description: t("resourceWhitelistSaveDescription")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
resourceQueries.resourceWhitelist({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("resourceErrorWhitelistSave"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("resourceErrorWhitelistSaveDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("otpEmailTitle")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("otpEmailTitleDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
{!env.email.emailEnabled && (
|
||||||
|
<Alert variant="neutral" className="mb-4">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{t("otpEmailSmtpRequired")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("otpEmailSmtpRequiredDescription")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<SwitchInput
|
||||||
|
id="whitelist-toggle"
|
||||||
|
label={t("otpEmailWhitelist")}
|
||||||
|
defaultChecked={resource.emailWhitelistEnabled}
|
||||||
|
onCheckedChange={setWhitelistEnabled}
|
||||||
|
disabled={!env.email.emailEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{whitelistEnabled && env.email.emailEnabled && (
|
||||||
|
<Form {...whitelistForm}>
|
||||||
|
<form id="whitelist-form">
|
||||||
|
<FormField
|
||||||
|
control={whitelistForm.control}
|
||||||
|
name="emails"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<InfoPopup
|
||||||
|
text={t(
|
||||||
|
"otpEmailWhitelistList"
|
||||||
|
)}
|
||||||
|
info={t(
|
||||||
|
"otpEmailWhitelistListDescription"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
activeTagIndex={
|
||||||
|
activeEmailTagIndex
|
||||||
|
}
|
||||||
|
size={"sm"}
|
||||||
|
validateTag={(tag) => {
|
||||||
|
return z
|
||||||
|
.email()
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
t(
|
||||||
|
"otpEmailErrorInvalid"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.safeParse(tag)
|
||||||
|
.success;
|
||||||
|
}}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveEmailTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"otpEmailEnter"
|
||||||
|
)}
|
||||||
|
tags={
|
||||||
|
whitelistForm.getValues()
|
||||||
|
.emails
|
||||||
|
}
|
||||||
|
setTags={(newRoles) => {
|
||||||
|
whitelistForm.setValue(
|
||||||
|
"emails",
|
||||||
|
newRoles as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("otpEmailEnterDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => startTransition(saveWhitelist)}
|
||||||
|
form="whitelist-form"
|
||||||
|
loading={loadingSaveWhitelist}
|
||||||
|
disabled={loadingSaveWhitelist}
|
||||||
|
>
|
||||||
|
{t("otpEmailWhitelistSave")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -15,31 +12,6 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionFooter
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { Label } from "@app/components/ui/label";
|
|
||||||
import { ListDomainsResponse } from "@server/routers/domain";
|
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -51,26 +23,39 @@ import {
|
|||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import { Globe } from "lucide-react";
|
import {
|
||||||
import { build } from "@server/build";
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Label } from "@app/components/ui/label";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { DomainRow } from "@app/components/DomainsTable";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { toASCII, toUnicode } from "punycode";
|
import { toASCII, toUnicode } from "punycode";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useActionState, useMemo, useState } from "react";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useForm } from "react-hook-form";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
const [formKey, setFormKey] = useState(0);
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const { org } = useOrgContext();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||||
const { licenseStatus } = useLicenseStatusContext();
|
|
||||||
const subscriptionStatus = useSubscriptionStatusContext();
|
|
||||||
const { user } = useUserContext();
|
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
@@ -78,20 +63,18 @@ export default function GeneralForm() {
|
|||||||
|
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
|
||||||
const [transferLoading, setTransferLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [baseDomains, setBaseDomains] = useState<
|
|
||||||
ListDomainsResponse["domains"]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [loadingPage, setLoadingPage] = useState(true);
|
|
||||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||||
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resourceFullDomainName = useMemo(() => {
|
||||||
|
const url = new URL(resourceFullDomain);
|
||||||
|
return url.hostname;
|
||||||
|
}, [resourceFullDomain]);
|
||||||
|
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
|
domainNamespaceId?: string;
|
||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
baseDomain: string;
|
baseDomain: string;
|
||||||
@@ -105,7 +88,6 @@ export default function GeneralForm() {
|
|||||||
niceId: z.string().min(1).max(255).optional(),
|
niceId: z.string().min(1).max(255).optional(),
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
proxyPort: z.int().min(1).max(65535).optional()
|
proxyPort: z.int().min(1).max(65535).optional()
|
||||||
// enableProxy: z.boolean().optional()
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -124,8 +106,6 @@ export default function GeneralForm() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -135,58 +115,17 @@ export default function GeneralForm() {
|
|||||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||||
domainId: resource.domainId || undefined,
|
domainId: resource.domainId || undefined,
|
||||||
proxyPort: resource.proxyPort || undefined
|
proxyPort: resource.proxyPort || undefined
|
||||||
// enableProxy: resource.enableProxy || false
|
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||||
const fetchSites = async () => {
|
|
||||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
|
||||||
`/org/${orgId}/sites/`
|
|
||||||
);
|
|
||||||
setSites(res.data.data.sites);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDomains = async () => {
|
async function onSubmit() {
|
||||||
const res = await api
|
const isValid = await form.trigger();
|
||||||
.get<
|
if (!isValid) return;
|
||||||
AxiosResponse<ListDomainsResponse>
|
|
||||||
>(`/org/${orgId}/domains/`)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("domainErrorFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("domainErrorFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res?.status === 200) {
|
const data = form.getValues();
|
||||||
const rawDomains = res.data.data.domains as DomainRow[];
|
|
||||||
const domains = rawDomains.map((domain) => ({
|
|
||||||
...domain,
|
|
||||||
baseDomain: toUnicode(domain.baseDomain)
|
|
||||||
}));
|
|
||||||
setBaseDomains(domains);
|
|
||||||
setFormKey((key) => key + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
await fetchDomains();
|
|
||||||
await fetchSites();
|
|
||||||
|
|
||||||
setLoadingPage(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function onSubmit(data: GeneralFormValues) {
|
|
||||||
setSaveLoading(true);
|
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||||
@@ -200,9 +139,6 @@ export default function GeneralForm() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort
|
||||||
// ...(!resource.http && {
|
|
||||||
// enableProxy: data.enableProxy
|
|
||||||
// })
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -225,7 +161,8 @@ export default function GeneralForm() {
|
|||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
fullDomain: updated.fullDomain,
|
fullDomain: updated.fullDomain,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort,
|
||||||
|
domainId: data.domainId
|
||||||
// ...(!resource.http && {
|
// ...(!resource.http && {
|
||||||
// enableProxy: data.enableProxy
|
// enableProxy: data.enableProxy
|
||||||
// })
|
// })
|
||||||
@@ -240,306 +177,265 @@ export default function GeneralForm() {
|
|||||||
router.replace(
|
router.replace(
|
||||||
`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`
|
`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveLoading(false);
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!loadingPage && (
|
<>
|
||||||
<>
|
<SettingsContainer>
|
||||||
<SettingsContainer>
|
<SettingsSection>
|
||||||
<SettingsSection>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionTitle>
|
||||||
<SettingsSectionTitle>
|
{t("resourceGeneral")}
|
||||||
{t("resourceGeneral")}
|
</SettingsSectionTitle>
|
||||||
</SettingsSectionTitle>
|
<SettingsSectionDescription>
|
||||||
<SettingsSectionDescription>
|
{t("resourceGeneralDescription")}
|
||||||
{t("resourceGeneralDescription")}
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionHeader>
|
||||||
</SettingsSectionHeader>
|
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form} key={formKey}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
action={formAction}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
render={({ field }) => (
|
render={() => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="enable-resource"
|
id="enable-resource"
|
||||||
defaultChecked={
|
defaultChecked={
|
||||||
resource.enabled
|
resource.enabled
|
||||||
}
|
}
|
||||||
label={t(
|
label={t(
|
||||||
"resourceEnable"
|
"resourceEnable"
|
||||||
)}
|
)}
|
||||||
onCheckedChange={(
|
onCheckedChange={(
|
||||||
|
val
|
||||||
|
) =>
|
||||||
|
form.setValue(
|
||||||
|
"enabled",
|
||||||
val
|
val
|
||||||
) =>
|
)
|
||||||
form.setValue(
|
}
|
||||||
"enabled",
|
/>
|
||||||
val
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!resource.http && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="proxyPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"resourcePortNumber"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={
|
||||||
|
field.value ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target
|
||||||
|
.value
|
||||||
|
? parseInt(
|
||||||
|
e
|
||||||
|
.target
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
<FormMessage />
|
||||||
<FormMessage />
|
<FormDescription>
|
||||||
</FormItem>
|
{t(
|
||||||
)}
|
"resourcePortNumberDescription"
|
||||||
/>
|
|
||||||
|
|
||||||
<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"
|
</FormDescription>
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
{!resource.http && (
|
{resource.http && (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<FormField
|
<Label>{t("resourceDomain")}</Label>
|
||||||
control={form.control}
|
<div className="border p-2 rounded-md flex items-center justify-between">
|
||||||
name="proxyPort"
|
<span className="text-sm flex items-center gap-2">
|
||||||
render={({ field }) => (
|
<Globe size="14" />
|
||||||
<FormItem>
|
{resourceFullDomain}
|
||||||
<FormLabel>
|
</span>
|
||||||
{t(
|
<Button
|
||||||
"resourcePortNumber"
|
variant="secondary"
|
||||||
)}
|
type="button"
|
||||||
</FormLabel>
|
size="sm"
|
||||||
<FormControl>
|
onClick={() =>
|
||||||
<Input
|
setEditDomainOpen(true)
|
||||||
type="number"
|
}
|
||||||
value={
|
>
|
||||||
field.value ??
|
{t("resourceEditDomain")}
|
||||||
""
|
</Button>
|
||||||
}
|
|
||||||
onChange={(
|
|
||||||
e
|
|
||||||
) =>
|
|
||||||
field.onChange(
|
|
||||||
e
|
|
||||||
.target
|
|
||||||
.value
|
|
||||||
? parseInt(
|
|
||||||
e
|
|
||||||
.target
|
|
||||||
.value
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<FormDescription>
|
|
||||||
{t(
|
|
||||||
"resourcePortNumberDescription"
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* {build == "oss" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enableProxy"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
variant={
|
|
||||||
"outlinePrimarySquare"
|
|
||||||
}
|
|
||||||
checked={
|
|
||||||
field.value
|
|
||||||
}
|
|
||||||
onCheckedChange={
|
|
||||||
field.onChange
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div className="space-y-1 leading-none">
|
|
||||||
<FormLabel>
|
|
||||||
{t(
|
|
||||||
"resourceEnableProxy"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
{t(
|
|
||||||
"resourceEnableProxyDescription"
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)} */}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{resource.http && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
{t("resourceDomain")}
|
|
||||||
</Label>
|
|
||||||
<div className="border p-2 rounded-md flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
||||||
<Globe size="14" />
|
|
||||||
{resourceFullDomain}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setEditDomainOpen(
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
"resourceEditDomain"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</form>
|
)}
|
||||||
</Form>
|
</form>
|
||||||
</SettingsSectionForm>
|
</Form>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={() => {
|
loading={saveLoading}
|
||||||
console.log(form.getValues());
|
disabled={saveLoading}
|
||||||
}}
|
form="general-settings-form"
|
||||||
loading={saveLoading}
|
>
|
||||||
disabled={saveLoading}
|
{t("saveSettings")}
|
||||||
form="general-settings-form"
|
</Button>
|
||||||
>
|
</SettingsSectionFooter>
|
||||||
{t("saveSettings")}
|
</SettingsSection>
|
||||||
</Button>
|
</SettingsContainer>
|
||||||
</SettingsSectionFooter>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsContainer>
|
|
||||||
|
|
||||||
<Credenza
|
<Credenza
|
||||||
open={editDomainOpen}
|
open={editDomainOpen}
|
||||||
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
|
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
|
||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>Edit Domain</CredenzaTitle>
|
<CredenzaTitle>Edit Domain</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
Select a domain for your resource
|
Select a domain for your resource
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<DomainPicker
|
<DomainPicker
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
cols={1}
|
cols={1}
|
||||||
onDomainChange={(res) => {
|
defaultSubdomain={
|
||||||
const selected = {
|
form.watch("subdomain") ?? resource.subdomain
|
||||||
domainId: res.domainId,
|
}
|
||||||
subdomain: res.subdomain,
|
defaultDomainId={
|
||||||
fullDomain: res.fullDomain,
|
form.watch("domainId") ?? resource.domainId
|
||||||
baseDomain: res.baseDomain
|
}
|
||||||
};
|
defaultFullDomain={resourceFullDomainName}
|
||||||
setSelectedDomain(selected);
|
onDomainChange={(res) => {
|
||||||
}}
|
const selected =
|
||||||
/>
|
res === null
|
||||||
</CredenzaBody>
|
? null
|
||||||
<CredenzaFooter>
|
: {
|
||||||
<CredenzaClose asChild>
|
domainId: res.domainId,
|
||||||
<Button variant="outline">{t("cancel")}</Button>
|
subdomain: res.subdomain,
|
||||||
</CredenzaClose>
|
fullDomain: res.fullDomain,
|
||||||
<Button
|
baseDomain: res.baseDomain,
|
||||||
onClick={() => {
|
domainNamespaceId:
|
||||||
if (selectedDomain) {
|
res.domainNamespaceId
|
||||||
const sanitizedSubdomain =
|
};
|
||||||
selectedDomain.subdomain
|
|
||||||
? finalizeSubdomainSanitize(
|
|
||||||
selectedDomain.subdomain
|
|
||||||
)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const sanitizedFullDomain =
|
setSelectedDomain(selected);
|
||||||
sanitizedSubdomain
|
}}
|
||||||
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
/>
|
||||||
: selectedDomain.baseDomain;
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("cancel")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedDomain) {
|
||||||
|
const sanitizedSubdomain =
|
||||||
|
selectedDomain.subdomain
|
||||||
|
? finalizeSubdomainSanitize(
|
||||||
|
selectedDomain.subdomain
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
|
||||||
setResourceFullDomain(
|
const sanitizedFullDomain =
|
||||||
`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`
|
sanitizedSubdomain
|
||||||
);
|
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
||||||
form.setValue(
|
: selectedDomain.baseDomain;
|
||||||
"domainId",
|
|
||||||
selectedDomain.domainId
|
|
||||||
);
|
|
||||||
form.setValue(
|
|
||||||
"subdomain",
|
|
||||||
sanitizedSubdomain
|
|
||||||
);
|
|
||||||
|
|
||||||
setEditDomainOpen(false);
|
setResourceFullDomain(
|
||||||
}
|
`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`
|
||||||
}}
|
);
|
||||||
>
|
form.setValue(
|
||||||
Select Domain
|
"domainId",
|
||||||
</Button>
|
selectedDomain.domainId
|
||||||
</CredenzaFooter>
|
);
|
||||||
</CredenzaContent>
|
form.setValue(
|
||||||
</Credenza>
|
"subdomain",
|
||||||
</>
|
sanitizedSubdomain
|
||||||
)
|
);
|
||||||
|
|
||||||
|
setEditDomainOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select Domain
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import ResourceInfoBox from "@app/components/ResourceInfoBox";
|
import ResourceInfoBox from "@app/components/ResourceInfoBox";
|
||||||
import { GetSiteResponse } from "@server/routers/site";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
interface ResourceLayoutProps {
|
interface ResourceLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ niceId: string; orgId: string }>;
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,8 @@ import {
|
|||||||
SettingsSectionTitle,
|
SettingsSectionTitle,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
@@ -74,6 +75,7 @@ import { Switch } from "@app/components/ui/switch";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -116,11 +118,15 @@ export default function ResourceRules(props: {
|
|||||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
||||||
|
useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const isMaxmindAvailable =
|
const isMaxmindAvailable =
|
||||||
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0;
|
env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0;
|
||||||
|
const isMaxmindAsnAvailable =
|
||||||
|
env.server.maxmind_asn_path && env.server.maxmind_asn_path.length > 0;
|
||||||
|
|
||||||
const RuleAction = {
|
const RuleAction = {
|
||||||
ACCEPT: t("alwaysAllow"),
|
ACCEPT: t("alwaysAllow"),
|
||||||
@@ -132,7 +138,8 @@ export default function ResourceRules(props: {
|
|||||||
PATH: t("path"),
|
PATH: t("path"),
|
||||||
IP: "IP",
|
IP: "IP",
|
||||||
CIDR: t("ipAddressRange"),
|
CIDR: t("ipAddressRange"),
|
||||||
COUNTRY: t("country")
|
COUNTRY: t("country"),
|
||||||
|
ASN: "ASN"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
const addRuleForm = useForm({
|
||||||
@@ -171,6 +178,30 @@ export default function ResourceRules(props: {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function addRule(data: z.infer<typeof addRuleSchema>) {
|
async function addRule(data: z.infer<typeof addRuleSchema>) {
|
||||||
|
// Normalize ASN value
|
||||||
|
if (data.match === "ASN") {
|
||||||
|
const originalValue = data.value.toUpperCase();
|
||||||
|
|
||||||
|
// Handle special "ALL" case
|
||||||
|
if (originalValue === "ALL" || originalValue === "AS0") {
|
||||||
|
data.value = "ALL";
|
||||||
|
} else {
|
||||||
|
// Remove AS prefix if present
|
||||||
|
const normalized = originalValue.replace(/^AS/, "");
|
||||||
|
if (!/^\d+$/.test(normalized)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid ASN",
|
||||||
|
description:
|
||||||
|
"ASN must be a number, optionally prefixed with 'AS' (e.g., AS15169 or 15169), or 'ALL'"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Add "AS" prefix for consistent storage
|
||||||
|
data.value = "AS" + normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isDuplicate = rules.some(
|
const isDuplicate = rules.some(
|
||||||
(rule) =>
|
(rule) =>
|
||||||
rule.action === data.action &&
|
rule.action === data.action &&
|
||||||
@@ -279,6 +310,8 @@ export default function ResourceRules(props: {
|
|||||||
return t("rulesMatchUrl");
|
return t("rulesMatchUrl");
|
||||||
case "COUNTRY":
|
case "COUNTRY":
|
||||||
return t("rulesMatchCountry");
|
return t("rulesMatchCountry");
|
||||||
|
case "ASN":
|
||||||
|
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,15 +482,16 @@ export default function ResourceRules(props: {
|
|||||||
type="number"
|
type="number"
|
||||||
onClick={(e) => e.currentTarget.focus()}
|
onClick={(e) => e.currentTarget.focus()}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const parsed = z
|
const parsed = z.coerce
|
||||||
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.optional()
|
.optional()
|
||||||
.safeParse(e.target.value);
|
.safeParse(e.target.value);
|
||||||
|
|
||||||
if (!parsed.data) {
|
if (!parsed.success) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("rulesErrorInvalidIpAddress"), // correct priority or IP?
|
title: t("rulesErrorInvalidPriority"), // correct priority or IP?
|
||||||
description: t(
|
description: t(
|
||||||
"rulesErrorInvalidPriorityDescription"
|
"rulesErrorInvalidPriorityDescription"
|
||||||
)
|
)
|
||||||
@@ -503,12 +537,12 @@ export default function ResourceRules(props: {
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY"
|
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
|
||||||
) =>
|
) =>
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
value:
|
value:
|
||||||
value === "COUNTRY" ? "US" : row.original.value
|
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : row.original.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -524,6 +558,11 @@ export default function ResourceRules(props: {
|
|||||||
{RuleMatch.COUNTRY}
|
{RuleMatch.COUNTRY}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isMaxmindAsnAvailable && (
|
||||||
|
<SelectItem value="ASN">
|
||||||
|
{RuleMatch.ASN}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
@@ -590,6 +629,93 @@ export default function ResourceRules(props: {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : row.original.match === "ASN" ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="min-w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{row.original.value
|
||||||
|
? (() => {
|
||||||
|
const found = MAJOR_ASNS.find(
|
||||||
|
(asn) =>
|
||||||
|
asn.code ===
|
||||||
|
row.original.value
|
||||||
|
);
|
||||||
|
return found
|
||||||
|
? `${found.name} (${row.original.value})`
|
||||||
|
: `Custom (${row.original.value})`;
|
||||||
|
})()
|
||||||
|
: "Select ASN"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="min-w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search ASNs or enter custom..."
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
No ASN found. Enter a custom ASN below.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{MAJOR_ASNS.map((asn) => (
|
||||||
|
<CommandItem
|
||||||
|
key={asn.code}
|
||||||
|
value={asn.name + " " + asn.code}
|
||||||
|
onSelect={() => {
|
||||||
|
updateRule(
|
||||||
|
row.original.ruleId,
|
||||||
|
{ value: asn.code }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
row.original.value ===
|
||||||
|
asn.code
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{asn.name} ({asn.code})
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter custom ASN (e.g., AS15169)"
|
||||||
|
defaultValue={
|
||||||
|
!MAJOR_ASNS.find(
|
||||||
|
(asn) =>
|
||||||
|
asn.code === row.original.value
|
||||||
|
)
|
||||||
|
? row.original.value
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const value = e.currentTarget.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/^AS/, "");
|
||||||
|
if (/^\d+$/.test(value)) {
|
||||||
|
updateRule(
|
||||||
|
row.original.ruleId,
|
||||||
|
{ value: "AS" + value }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.value}
|
defaultValue={row.original.value}
|
||||||
@@ -800,6 +926,13 @@ export default function ResourceRules(props: {
|
|||||||
}
|
}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isMaxmindAsnAvailable && (
|
||||||
|
<SelectItem value="ASN">
|
||||||
|
{
|
||||||
|
RuleMatch.ASN
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -922,6 +1055,115 @@ export default function ResourceRules(props: {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : addRuleForm.watch(
|
||||||
|
"match"
|
||||||
|
) === "ASN" ? (
|
||||||
|
<Popover
|
||||||
|
open={
|
||||||
|
openAddRuleAsnSelect
|
||||||
|
}
|
||||||
|
onOpenChange={
|
||||||
|
setOpenAddRuleAsnSelect
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={
|
||||||
|
openAddRuleAsnSelect
|
||||||
|
}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? MAJOR_ASNS.find(
|
||||||
|
(
|
||||||
|
asn
|
||||||
|
) =>
|
||||||
|
asn.code ===
|
||||||
|
field.value
|
||||||
|
)
|
||||||
|
?.name +
|
||||||
|
" (" +
|
||||||
|
field.value +
|
||||||
|
")" || field.value
|
||||||
|
: "Select ASN"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search ASNs or enter custom..."
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
No ASN found. Use the custom input below.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{MAJOR_ASNS.map(
|
||||||
|
(
|
||||||
|
asn
|
||||||
|
) => (
|
||||||
|
<CommandItem
|
||||||
|
key={
|
||||||
|
asn.code
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
asn.name + " " + asn.code
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(
|
||||||
|
asn.code
|
||||||
|
);
|
||||||
|
setOpenAddRuleAsnSelect(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
field.value ===
|
||||||
|
asn.code
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
asn.name
|
||||||
|
}{" "}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
asn.code
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter custom ASN (e.g., AS15169)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const value = e.currentTarget.value
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/^AS/, "");
|
||||||
|
if (/^\d+$/.test(value)) {
|
||||||
|
field.onChange("AS" + value);
|
||||||
|
setOpenAddRuleAsnSelect(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
)}
|
)}
|
||||||
@@ -1017,17 +1259,16 @@ export default function ResourceRules(props: {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
onClick={saveAllSettings}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("saveAllSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={saveAllSettings}
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{t("saveAllSettings")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1312,6 +1312,35 @@ export default function Page() {
|
|||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
{resourceTypes.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("type")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StrategySelect
|
||||||
|
options={resourceTypes}
|
||||||
|
defaultValue="http"
|
||||||
|
onChange={(value) => {
|
||||||
|
baseForm.setValue(
|
||||||
|
"http",
|
||||||
|
value === "http"
|
||||||
|
);
|
||||||
|
// Update method default when switching resource type
|
||||||
|
addTargetForm.setValue(
|
||||||
|
"method",
|
||||||
|
value === "http"
|
||||||
|
? "http"
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
cols={2}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...baseForm}>
|
<Form {...baseForm}>
|
||||||
<form
|
<form
|
||||||
@@ -1348,35 +1377,6 @@ export default function Page() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|
||||||
{resourceTypes.length > 1 && (
|
|
||||||
<>
|
|
||||||
<div className="mb-2">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{t("type")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StrategySelect
|
|
||||||
options={resourceTypes}
|
|
||||||
defaultValue="http"
|
|
||||||
onChange={(value) => {
|
|
||||||
baseForm.setValue(
|
|
||||||
"http",
|
|
||||||
value === "http"
|
|
||||||
);
|
|
||||||
// Update method default when switching resource type
|
|
||||||
addTargetForm.setValue(
|
|
||||||
"method",
|
|
||||||
value === "http"
|
|
||||||
? "http"
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
cols={2}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
@@ -1396,6 +1396,8 @@ export default function Page() {
|
|||||||
<DomainPicker
|
<DomainPicker
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
onDomainChange={(res) => {
|
onDomainChange={(res) => {
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
httpForm.setValue(
|
httpForm.setValue(
|
||||||
"subdomain",
|
"subdomain",
|
||||||
res.subdomain
|
res.subdomain
|
||||||
@@ -1682,7 +1684,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center p-4">
|
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{t("targetNoOne")}
|
{t("targetNoOne")}
|
||||||
</p>
|
</p>
|
||||||
@@ -1848,7 +1850,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
className="text-sm text-primary flex items-center gap-1"
|
className="text-sm text-primary flex items-center gap-1"
|
||||||
href="https://docs.pangolin.net/manage/resources/tcp-udp-resources"
|
href="https://docs.pangolin.net/manage/resources/public/raw-resources"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
|
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
|
||||||
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
|
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
@@ -97,6 +98,8 @@ export default async function ProxyResourcesPage(
|
|||||||
description={t("proxyResourceDescription")}
|
description={t("proxyResourceDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProxyResourcesBanner />
|
||||||
|
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<ProxyResourcesTable
|
<ProxyResourcesTable
|
||||||
resources={resourceRows}
|
resources={resourceRows}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSectionContent,
|
InfoSectionContent,
|
||||||
@@ -39,6 +38,7 @@ import {
|
|||||||
generateObfuscatedWireGuardConfig
|
generateObfuscatedWireGuardConfig
|
||||||
} from "@app/lib/wireguard";
|
} from "@app/lib/wireguard";
|
||||||
import { QRCodeCanvas } from "qrcode.react";
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
|
||||||
export default function CredentialsPage() {
|
export default function CredentialsPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -203,7 +203,7 @@ export default function CredentialsPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
@@ -300,7 +300,7 @@ export default function CredentialsPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
{!loadingDefaults && (
|
{!loadingDefaults && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user