mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-17 11:06:30 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 706f91e829 | |||
| 84c7111427 | |||
| 9512a4ee71 | |||
| 22f0eb0d59 | |||
| 77987924af | |||
| 0547a4fde3 | |||
| dd53d90eb5 | |||
| f9ab1627fd | |||
| f18ae7f16d | |||
| 54f3c0a0c4 | |||
| dc03bf8674 | |||
| afc28713e3 | |||
| 9ac7b28455 | |||
| b13495f1e0 |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Proxy resources = public resources
|
|
||||||
Private resources = client resources = site resources
|
|
||||||
+2
-2
@@ -28,9 +28,9 @@ LICENSE
|
|||||||
CONTRIBUTING.md
|
CONTRIBUTING.md
|
||||||
dist
|
dist
|
||||||
.git
|
.git
|
||||||
server/migrations/
|
migrations/
|
||||||
config/
|
config/
|
||||||
build.ts
|
build.ts
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
drizzle.config.ts
|
migrations/
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
* @oschwartz10612 @miloschwartz
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [fosrl]
|
||||||
@@ -14,13 +14,12 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version:
|
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
- Edition (Community or Enterprise):
|
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Client Version:
|
- Olm Version: (if applicable)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
+112
-48
@@ -29,7 +29,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v6
|
uses: aws-actions/configure-aws-credentials@v5
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -201,10 +201,10 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
@@ -264,9 +264,9 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.24
|
||||||
|
|
||||||
- name: Update version in package.json
|
- name: Update version in package.json
|
||||||
run: |
|
run: |
|
||||||
@@ -289,17 +289,25 @@ jobs:
|
|||||||
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Update install/main.go
|
||||||
|
run: |
|
||||||
|
PANGOLIN_VERSION=${{ env.TAG }}
|
||||||
|
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
|
||||||
|
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
|
||||||
|
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
|
||||||
|
cat install/main.go
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Build installer
|
- name: Build installer
|
||||||
working-directory: install
|
working-directory: install
|
||||||
run: |
|
run: |
|
||||||
make go-build-release \
|
make go-build-release
|
||||||
PANGOLIN_VERSION=${{ env.TAG }} \
|
|
||||||
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \
|
|
||||||
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload artifacts from /install/bin
|
- name: Upload artifacts from /install/bin
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: install-bin
|
name: install-bin
|
||||||
path: install/bin/
|
path: install/bin/
|
||||||
@@ -407,27 +415,35 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
# cosign is used to sign container images using keyless (OIDC) signing
|
# cosign is used to sign and verify container images (key and keyless)
|
||||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||||
with:
|
|
||||||
cosign-release: v3.0.6
|
|
||||||
|
|
||||||
- name: Sign (GHCR, keyless)
|
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||||
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||||
# Signatures are stored in the registry alongside the image.
|
# then verify both the public key signature and the keyless OIDC signature.
|
||||||
env:
|
env:
|
||||||
TAG: ${{ env.TAG }}
|
TAG: ${{ env.TAG }}
|
||||||
|
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||||
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
|
||||||
COSIGN_YES: "true"
|
COSIGN_YES: "true"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
issuer="https://token.actions.githubusercontent.com"
|
||||||
|
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
||||||
|
|
||||||
|
# Track failures
|
||||||
|
FAILED_TAGS=()
|
||||||
|
SUCCESSFUL_TAGS=()
|
||||||
|
|
||||||
# Determine if this is an RC release
|
# Determine if this is an RC release
|
||||||
IS_RC="false"
|
IS_RC="false"
|
||||||
if [[ "$TAG" == *"-rc."* ]]; then
|
if [[ "$TAG" == *"-rc."* ]]; then
|
||||||
@@ -455,47 +471,95 @@ jobs:
|
|||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FAILED_TAGS=()
|
# Sign each image variant for both registries
|
||||||
SUCCESSFUL_TAGS=()
|
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
||||||
|
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
||||||
|
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
TAG_FAILED=false
|
||||||
|
|
||||||
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
# Wrap the entire tag processing in error handling
|
||||||
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
|
(
|
||||||
TAG_FAILED=false
|
set -e
|
||||||
|
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
||||||
|
REF="${BASE_IMAGE}@${DIGEST}"
|
||||||
|
echo "Resolved digest: ${REF}"
|
||||||
|
|
||||||
(
|
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||||
set -e
|
cosign sign --recursive "${REF}"
|
||||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
|
||||||
REF="${GHCR_IMAGE}@${DIGEST}"
|
|
||||||
echo "Resolved digest: ${REF}"
|
|
||||||
|
|
||||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
echo "==> cosign sign (key) --recursive ${REF}"
|
||||||
cosign sign --recursive "${REF}"
|
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||||
) || TAG_FAILED=true
|
|
||||||
|
|
||||||
if [ "$TAG_FAILED" = "true" ]; then
|
# Retry wrapper for verification to handle registry propagation delays
|
||||||
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
|
retry_verify() {
|
||||||
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
|
local cmd="$1"
|
||||||
else
|
local attempts=6
|
||||||
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
|
local delay=5
|
||||||
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
|
local i=1
|
||||||
fi
|
until eval "$cmd"; do
|
||||||
|
if [ $i -ge $attempts ]; then
|
||||||
|
echo "Verification failed after $attempts attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
||||||
|
sleep $delay
|
||||||
|
i=$((i+1))
|
||||||
|
delay=$((delay*2))
|
||||||
|
# Cap the delay to avoid very long waits
|
||||||
|
if [ $delay -gt 60 ]; then delay=60; fi
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> cosign verify (public key) ${REF}"
|
||||||
|
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
||||||
|
VERIFIED_INDEX=true
|
||||||
|
else
|
||||||
|
VERIFIED_INDEX=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> cosign verify (keyless policy) ${REF}"
|
||||||
|
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
||||||
|
VERIFIED_INDEX_KEYLESS=true
|
||||||
|
else
|
||||||
|
VERIFIED_INDEX_KEYLESS=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if verification succeeded
|
||||||
|
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
||||||
|
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
echo "This may be due to registry propagation delays. Continuing anyway."
|
||||||
|
fi
|
||||||
|
) || TAG_FAILED=true
|
||||||
|
|
||||||
|
if [ "$TAG_FAILED" = "true" ]; then
|
||||||
|
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||||
|
else
|
||||||
|
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Report summary
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Sign Summary"
|
echo "Sign and Verify Summary"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
||||||
echo "Failed: ${#FAILED_TAGS[@]}"
|
echo "Failed: ${#FAILED_TAGS[@]}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
||||||
echo "Failed tags:"
|
echo "Failed tags:"
|
||||||
for tag in "${FAILED_TAGS[@]}"; do
|
for tag in "${FAILED_TAGS[@]}"; do
|
||||||
echo " - $tag"
|
echo " - $tag"
|
||||||
done
|
done
|
||||||
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
|
echo ""
|
||||||
|
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
|
||||||
else
|
else
|
||||||
echo "✓ All images signed successfully!"
|
echo "✓ All images signed and verified successfully!"
|
||||||
fi
|
fi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
@@ -514,7 +578,7 @@ jobs:
|
|||||||
permissions: write-all
|
permissions: write-all
|
||||||
steps:
|
steps:
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v6
|
uses: aws-actions/configure-aws-credentials@v5
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
role-duration-seconds: 3600
|
role-duration-seconds: 3600
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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@v5
|
||||||
|
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"
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
name: SAAS Pipeline
|
||||||
|
|
||||||
|
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||||
|
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write # for GHCR push
|
||||||
|
id-token: write # for Cosign Keyless (OIDC) Signing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v5
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Verify AWS identity
|
||||||
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
|
- name: Start EC2 instances
|
||||||
|
run: |
|
||||||
|
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||||
|
echo "EC2 instances started"
|
||||||
|
|
||||||
|
|
||||||
|
release-arm:
|
||||||
|
name: Build and Release (ARM64)
|
||||||
|
runs-on: [self-hosted, linux, arm64, us-east-1]
|
||||||
|
needs: [pre-run]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
needs.pre-run.result == 'success'
|
||||||
|
}}
|
||||||
|
# Job-level timeout to avoid runaway or stuck runs
|
||||||
|
timeout-minutes: 120
|
||||||
|
env:
|
||||||
|
# Target images
|
||||||
|
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
|
- name: Download MaxMind GeoLite2 databases
|
||||||
|
env:
|
||||||
|
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "Downloading MaxMind GeoLite2 databases..."
|
||||||
|
|
||||||
|
# Download GeoLite2-Country
|
||||||
|
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||||
|
-o GeoLite2-Country.tar.gz
|
||||||
|
|
||||||
|
# Download GeoLite2-ASN
|
||||||
|
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||||
|
-o GeoLite2-ASN.tar.gz
|
||||||
|
|
||||||
|
# Extract the .mmdb files
|
||||||
|
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||||
|
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||||
|
|
||||||
|
# Verify files exist
|
||||||
|
if [ ! -f "GeoLite2-Country.mmdb" ]; then
|
||||||
|
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
|
||||||
|
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up tar files
|
||||||
|
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
|
||||||
|
|
||||||
|
echo "MaxMind databases downloaded successfully"
|
||||||
|
ls -lh GeoLite2-*.mmdb
|
||||||
|
|
||||||
|
- name: Monitor storage space
|
||||||
|
run: |
|
||||||
|
THRESHOLD=75
|
||||||
|
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
|
||||||
|
echo "Used space: $USED_SPACE%"
|
||||||
|
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
|
||||||
|
echo "Used space is below the threshold of 75% free. Running Docker system prune."
|
||||||
|
echo y | docker system prune -a
|
||||||
|
else
|
||||||
|
echo "Storage space is above the threshold. No action needed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v5
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR
|
||||||
|
id: login-ecr
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
|
||||||
|
- name: Extract tag name
|
||||||
|
id: get-tag
|
||||||
|
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Update version in package.json
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
||||||
|
cat server/lib/consts.ts
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Build and push Docker images (Docker Hub - ARM64)
|
||||||
|
run: |
|
||||||
|
TAG=${{ env.TAG }}
|
||||||
|
make build-saas tag=$TAG
|
||||||
|
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
post-run:
|
||||||
|
needs: [pre-run, release-arm]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
always() &&
|
||||||
|
needs.pre-run.result == 'success' &&
|
||||||
|
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
|
||||||
|
}}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: write-all
|
||||||
|
steps:
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v5
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||||
|
role-duration-seconds: 3600
|
||||||
|
aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
|
- name: Verify AWS identity
|
||||||
|
run: aws sts get-caller-identity
|
||||||
|
|
||||||
|
- name: Stop EC2 instances
|
||||||
|
run: |
|
||||||
|
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||||
|
echo "EC2 instances stopped"
|
||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||||
with:
|
with:
|
||||||
days-before-stale: 14
|
days-before-stale: 14
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
+5
-15
@@ -1,9 +1,8 @@
|
|||||||
# FROM node:24-slim AS base
|
FROM node:24-alpine AS base
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
@@ -24,20 +23,15 @@ RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
|||||||
npm run build:cli && \
|
npm run build:cli && \
|
||||||
test -f dist/server.mjs
|
test -f dist/server.mjs
|
||||||
|
|
||||||
# Create placeholder files for MaxMind databases to avoid COPY errors
|
|
||||||
# Real files should be present for saas builds, placeholders for oss builds
|
|
||||||
RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb
|
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# FROM node:24-slim AS runner
|
FROM node:24-alpine AS runner
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/*
|
RUN apk add --no-cache curl tzdata
|
||||||
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json ./package.json
|
||||||
@@ -57,16 +51,12 @@ COPY public ./public
|
|||||||
|
|
||||||
# Copy MaxMind databases for SaaS builds
|
# Copy MaxMind databases for SaaS builds
|
||||||
ARG BUILD=oss
|
ARG BUILD=oss
|
||||||
|
|
||||||
RUN mkdir -p ./maxmind
|
RUN mkdir -p ./maxmind
|
||||||
|
|
||||||
# Copy MaxMind databases (placeholders exist for oss builds, real files for saas)
|
# This is only for saas
|
||||||
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
|
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
|
||||||
COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb
|
COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb
|
||||||
|
|
||||||
# Remove MaxMind databases for non-saas builds (keep only for saas)
|
|
||||||
RUN if [ "$BUILD" != "saas" ]; then rm -rf ./maxmind; fi
|
|
||||||
|
|
||||||
# OCI Image Labels - Build Args for dynamic values
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
ARG VERSION="dev"
|
ARG VERSION="dev"
|
||||||
ARG REVISION=""
|
ARG REVISION=""
|
||||||
|
|||||||
@@ -35,53 +35,43 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://docs.pangolin.net/careers/join-us">
|
||||||
|
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>
|
<strong>
|
||||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
- Get started for free with [Pangolin Cloud](https://app.pangolin.net/).
|
- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin.
|
||||||
- Or, check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to self-host Pangolin.
|
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
|
||||||
|
|
||||||
<img src="public/screenshots/hero.png" alt="Pangolin" width="100%" />
|
<img src="public/screenshots/hero.png" />
|
||||||
|
|
||||||
## Deployment Options
|
## Deployment Options
|
||||||
|
|
||||||
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
|
| <img width=500 /> | Description |
|
||||||
- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3.
|
|-----------------|--------------|
|
||||||
- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
||||||
|
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
||||||
|
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/nodes) and connect to our control plane. |
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### Connect remote networks with sites and NAT traversal
|
| <img width=500 /> | <img width=500 /> |
|
||||||
|
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
|
||||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
| **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
|
||||||
|
| **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
|
||||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
| **Client-based private resource access**<br /><br />Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> |
|
||||||
|
| **Zero-trust granular access**<br /><br />Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
|
||||||
### Browser-based reverse proxy access
|
|
||||||
|
|
||||||
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
|
||||||
|
|
||||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
|
||||||
|
|
||||||
### Client-based private resource access
|
|
||||||
|
|
||||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
|
||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
|
||||||
|
|
||||||
### Give users and roles access to resources
|
|
||||||
|
|
||||||
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
|
||||||
|
|
||||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
|
||||||
|
|
||||||
## Download Clients
|
## Download Clients
|
||||||
|
|
||||||
@@ -95,16 +85,17 @@ Download the Pangolin client for your platform:
|
|||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
### Sign up now
|
|
||||||
|
|
||||||
Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud.
|
|
||||||
|
|
||||||
### Check out the docs
|
### Check out the docs
|
||||||
|
|
||||||
We encourage everyone to read the full documentation first, which is
|
We encourage everyone to read the full documentation first, which is
|
||||||
available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of
|
available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of
|
||||||
the docs to illustrate some basic ideas.
|
the docs to illustrate some basic ideas.
|
||||||
|
|
||||||
|
### Sign up and try now
|
||||||
|
|
||||||
|
For Pangolin's managed service, you will first need to create an account at
|
||||||
|
[app.pangolin.net](https://app.pangolin.net). We have a generous free tier to get started.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
||||||
@@ -112,3 +103,7 @@ Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License
|
|||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
WireGuard® is a registered trademark of Jason A. Donenfeld.
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
|
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
|
||||||
|
|
||||||
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
|
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
|
||||||
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) with the following information:
|
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
|
||||||
|
|
||||||
- Description and location of the vulnerability.
|
- Description and location of the vulnerability.
|
||||||
- Potential impact of the vulnerability.
|
- Potential impact of the vulnerability.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: Create API Key
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: http://localhost:3000/api/v1/api-key
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"isRoot": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Delete API Key
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: http://localhost:3000/api/v1/api-key/dm47aacqxxn3ubj
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: List API Key Actions
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: List Org API Keys
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/org/home-lab/api-keys
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: List Root API Keys
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/root/api-keys
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: Set API Key Actions
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"actionIds": ["listSites"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: Set API Key Orgs
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/orgs
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"orgIds": ["home-lab"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
meta {
|
||||||
|
name: API Keys
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: 2fa-disable
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/2fa/disable
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"password": "aaaaa-1A",
|
||||||
|
"code": "377289"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: 2fa-enable
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/2fa/enable
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"code": "374138"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: 2fa-request
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/2fa/request
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"password": "aaaaa-1A"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: change-password
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/change-password
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"oldPassword": "",
|
||||||
|
"newPassword": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: login
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/login
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"email": "admin@fosrl.io",
|
||||||
|
"password": "Password123!"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: logout
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:4000/api/v1/auth/logout
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: reset-password-request
|
||||||
|
type: http
|
||||||
|
seq: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/reset-password/request
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"email": "milo@pangolin.net"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
meta {
|
||||||
|
name: reset-password
|
||||||
|
type: http
|
||||||
|
seq: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/reset-password
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"token": "3uhsbom72dwdhboctwrtntyd6jrlg4jtf5oaxy4k",
|
||||||
|
"newPassword": "aaaaa-1A",
|
||||||
|
"code": "6irqCGR3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: signup
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: http://localhost:3000/api/v1/auth/signup
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"email": "numbat@pangolin.net",
|
||||||
|
"password": "Password123!"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: verify-email-request
|
||||||
|
type: http
|
||||||
|
seq: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/verify-email/request
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
meta {
|
||||||
|
name: verify-email
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: http://localhost:3000/api/v1/auth/verify-email
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"code": "50317187"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: verify-user
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3001/api/v1/badger/verify-user?sessionId=mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
params:query {
|
||||||
|
sessionId: mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: createClient
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: http://localhost:3000/api/v1/site/1/client
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"siteId": 1,
|
||||||
|
"name": "test",
|
||||||
|
"type": "olm",
|
||||||
|
"subnet": "100.90.129.4/30",
|
||||||
|
"olmId": "029yzunhx6nh3y5",
|
||||||
|
"secret": "l0ymp075y3d4rccb25l6sqpgar52k09etunui970qq5gj7x6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: pickClientDefaults
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/site/1/pick-client-defaults
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
meta {
|
||||||
|
name: Create OIDC Provider
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: http://localhost:3000/api/v1/org/home-lab/idp/oidc
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"clientId": "JJoSvHCZcxnXT2sn6CObj6a21MuKNRXs3kN5wbys",
|
||||||
|
"clientSecret": "2SlGL2wOGgMEWLI9yUuMAeFxre7qSNJVnXMzyepdNzH1qlxYnC4lKhhQ6a157YQEkYH3vm40KK4RCqbYiF8QIweuPGagPX3oGxEj2exwutoXFfOhtq4hHybQKoFq01Z3",
|
||||||
|
"authUrl": "http://localhost:9000/application/o/authorize/",
|
||||||
|
"tokenUrl": "http://localhost:9000/application/o/token/",
|
||||||
|
"scopes": ["email", "openid", "profile"],
|
||||||
|
"userIdentifier": "email"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Generate OIDC URL
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
meta {
|
||||||
|
name: IDP
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Traefik Config
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3001/api/v1/traefik-config
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
meta {
|
||||||
|
name: Internal
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Create Newt
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/newt
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Token
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/auth/newt/get-token
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"newtId": "o0d4rdxq3stnz7b",
|
||||||
|
"secret": "sy7l09fnaesd03iwrfp9m3qf0ryn19g0zf3dqieaazb4k7vk"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: createOlm
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: http://localhost:3000/api/v1/olm
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: Olm
|
||||||
|
seq: 15
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Check Id
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/org/checkId
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: listOrgs
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url:
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: createRemoteExitNode
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: http://localhost:4000/api/v1/org/org_i21aifypnlyxur2/remote-exit-node
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: listResourcesByOrg
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url:
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: listResourcesBySite
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/site/1/resources?limit=10&offset=0
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
params:query {
|
||||||
|
limit: 10
|
||||||
|
offset: 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Site
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/org/test/sites/mexican-mole-lizard-windy
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: listSites
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url:
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
meta {
|
||||||
|
name: listTargets
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/resource/web.main.localhost/targets?limit=10&offset=0
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
params:query {
|
||||||
|
limit: 10
|
||||||
|
offset: 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Test
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: traefik-config
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3001/api/v1/traefik-config
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: adminListUsers
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: http://localhost:3000/api/v1/users
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: adminRemoveUser
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: getUser
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url:
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "Pangolin",
|
||||||
|
"type": "collection",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git"
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"requestType": "http",
|
||||||
|
"requestUrl": "http://localhost:3000/api/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { CommandModule } from "yargs";
|
|
||||||
import { db, certificates } from "@server/db";
|
|
||||||
|
|
||||||
type ClearCertificatesArgs = {};
|
|
||||||
|
|
||||||
export const clearCertificates: CommandModule<{}, ClearCertificatesArgs> = {
|
|
||||||
command: "clear-certificates",
|
|
||||||
describe: "Delete all entries from the certificates table",
|
|
||||||
builder: (yargs) => {
|
|
||||||
return yargs;
|
|
||||||
},
|
|
||||||
handler: async (argv: {}) => {
|
|
||||||
try {
|
|
||||||
console.log("Clearing all certificates from the database...");
|
|
||||||
|
|
||||||
const deleted = await db.delete(certificates).returning();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Deleted ${deleted.length} certificate(s) from the database`
|
|
||||||
);
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { CommandModule } from "yargs";
|
|
||||||
import { db, users } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable 2FA for a user by email address.
|
|
||||||
*/
|
|
||||||
type DisableUser2faArgs = {
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
|
|
||||||
command: "disable-user-2fa",
|
|
||||||
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
|
|
||||||
builder: (yargs) => {
|
|
||||||
return yargs.option("email", {
|
|
||||||
type: "string",
|
|
||||||
demandOption: true,
|
|
||||||
describe: "User email address"
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handler: async (argv: { email: string }) => {
|
|
||||||
try {
|
|
||||||
const { email } = argv;
|
|
||||||
console.log(`Looking for user with email: ${email}`);
|
|
||||||
|
|
||||||
// Find the user by email
|
|
||||||
const [user] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.email, email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error(`User with email '${email}' not found`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.twoFactorEnabled) {
|
|
||||||
console.log(`2FA is already disabled for user '${email}'.`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user: disable 2FA and clear secret
|
|
||||||
await db.update(users)
|
|
||||||
.set({
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
twoFactorSecret: null,
|
|
||||||
twoFactorSetupRequested: false
|
|
||||||
})
|
|
||||||
.where(eq(users.userId, user.userId));
|
|
||||||
|
|
||||||
console.log(`2FA disabled for user '${email}'.`);
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error disabling 2FA:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,7 @@ import { db, orgs } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { generateCA } from "@server/lib/sshCA";
|
import { generateCA } from "@server/private/lib/sshCA";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommandModule } from "yargs";
|
import { CommandModule } from "yargs";
|
||||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
import { db, idpOidcConfig, licenseKey } from "@server/db";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -129,15 +129,9 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log("\nReading encrypted data from database...");
|
console.log("\nReading encrypted data from database...");
|
||||||
const idpConfigs = await db.select().from(idpOidcConfig);
|
const idpConfigs = await db.select().from(idpOidcConfig);
|
||||||
const licenseKeys = await db.select().from(licenseKey);
|
const licenseKeys = await db.select().from(licenseKey);
|
||||||
const certs = await db.select().from(certificates);
|
|
||||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
|
||||||
const webhookActions = await db.select().from(alertWebhookActions);
|
|
||||||
|
|
||||||
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
console.log(`Found ${certs.length} certificate(s)`);
|
|
||||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
|
||||||
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
|
||||||
|
|
||||||
// Prepare all decrypted and re-encrypted values
|
// Prepare all decrypted and re-encrypted values
|
||||||
console.log("\nDecrypting and re-encrypting values...");
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
@@ -155,27 +149,8 @@ export const rotateServerSecret: CommandModule<
|
|||||||
encryptedInstanceId: string;
|
encryptedInstanceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CertUpdate = {
|
|
||||||
certId: number;
|
|
||||||
encryptedCertFile: string | null;
|
|
||||||
encryptedKeyFile: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StreamingDestinationUpdate = {
|
|
||||||
destinationId: number;
|
|
||||||
encryptedConfig: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WebhookActionUpdate = {
|
|
||||||
webhookActionId: number;
|
|
||||||
encryptedConfig: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const idpUpdates: IdpUpdate[] = [];
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
const certUpdates: CertUpdate[] = [];
|
|
||||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
|
||||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
|
||||||
|
|
||||||
// Process idpOidcConfig entries
|
// Process idpOidcConfig entries
|
||||||
for (const idpConfig of idpConfigs) {
|
for (const idpConfig of idpConfigs) {
|
||||||
@@ -242,70 +217,6 @@ export const rotateServerSecret: CommandModule<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process certificate entries
|
|
||||||
for (const cert of certs) {
|
|
||||||
try {
|
|
||||||
const encryptedCertFile = cert.certFile
|
|
||||||
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
|
|
||||||
: null;
|
|
||||||
const encryptedKeyFile = cert.keyFile
|
|
||||||
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
certUpdates.push({
|
|
||||||
certId: cert.certId,
|
|
||||||
encryptedCertFile,
|
|
||||||
encryptedKeyFile
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error processing certificate ${cert.certId} (${cert.domain}):`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process eventStreamingDestinations entries
|
|
||||||
for (const dest of streamingDestinations) {
|
|
||||||
try {
|
|
||||||
const decryptedConfig = decrypt(dest.config, oldSecret);
|
|
||||||
const encryptedConfig = encrypt(decryptedConfig, newSecret);
|
|
||||||
|
|
||||||
streamingDestinationUpdates.push({
|
|
||||||
destinationId: dest.destinationId,
|
|
||||||
encryptedConfig
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error processing event streaming destination ${dest.destinationId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process alertWebhookActions entries
|
|
||||||
for (const webhook of webhookActions) {
|
|
||||||
try {
|
|
||||||
if (webhook.config == null) continue;
|
|
||||||
|
|
||||||
const decryptedConfig = decrypt(webhook.config, oldSecret);
|
|
||||||
const encryptedConfig = encrypt(decryptedConfig, newSecret);
|
|
||||||
|
|
||||||
webhookActionUpdates.push({
|
|
||||||
webhookActionId: webhook.webhookActionId,
|
|
||||||
encryptedConfig
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error processing alert webhook action ${webhook.webhookActionId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform all database updates in a single transaction
|
// Perform all database updates in a single transaction
|
||||||
console.log("\nUpdating database in transaction...");
|
console.log("\nUpdating database in transaction...");
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -339,50 +250,10 @@ export const rotateServerSecret: CommandModule<
|
|||||||
instanceId: update.encryptedInstanceId
|
instanceId: update.encryptedInstanceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update certificate entries
|
|
||||||
for (const update of certUpdates) {
|
|
||||||
await trx
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
certFile: update.encryptedCertFile,
|
|
||||||
keyFile: update.encryptedKeyFile
|
|
||||||
})
|
|
||||||
.where(eq(certificates.certId, update.certId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update event streaming destination entries
|
|
||||||
for (const update of streamingDestinationUpdates) {
|
|
||||||
await trx
|
|
||||||
.update(eventStreamingDestinations)
|
|
||||||
.set({ config: update.encryptedConfig })
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
eventStreamingDestinations.destinationId,
|
|
||||||
update.destinationId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update alert webhook action entries
|
|
||||||
for (const update of webhookActionUpdates) {
|
|
||||||
await trx
|
|
||||||
.update(alertWebhookActions)
|
|
||||||
.set({ config: update.encryptedConfig })
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
alertWebhookActions.webhookActionId,
|
|
||||||
update.webhookActionId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
|
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
|
||||||
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
|
||||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
|
||||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
|
||||||
|
|
||||||
// Update config file with new secret
|
// Update config file with new secret
|
||||||
console.log("\nUpdating config file...");
|
console.log("\nUpdating config file...");
|
||||||
@@ -399,9 +270,6 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(`\nSummary:`);
|
console.log(`\nSummary:`);
|
||||||
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
|
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
|
||||||
console.log(` - License keys: ${licenseKeyUpdates.length}`);
|
console.log(` - License keys: ${licenseKeyUpdates.length}`);
|
||||||
console.log(` - Certificates: ${certUpdates.length}`);
|
|
||||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
|
||||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
|
|||||||
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||||
import { deleteClient } from "./commands/deleteClient";
|
import { deleteClient } from "./commands/deleteClient";
|
||||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||||
import { clearCertificates } from "./commands/clearCertificates";
|
|
||||||
import { disableUser2fa } from "./commands/disableUser2fa";
|
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
@@ -21,7 +19,5 @@ yargs(hideBin(process.argv))
|
|||||||
.command(clearLicenseKeys)
|
.command(clearLicenseKeys)
|
||||||
.command(deleteClient)
|
.command(deleteClient)
|
||||||
.command(generateOrgCaKeys)
|
.command(generateOrgCaKeys)
|
||||||
.command(clearCertificates)
|
|
||||||
.command(disableUser2fa)
|
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
*-journal
|
|
||||||
@@ -4,12 +4,6 @@ services:
|
|||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:latest
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 1g
|
|
||||||
reservations:
|
|
||||||
memory: 256m
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
services:
|
|
||||||
mailer:
|
|
||||||
image: axllent/mailpit
|
|
||||||
ports:
|
|
||||||
- 8025:8025
|
|
||||||
- 1025:1025
|
|
||||||
volumes:
|
|
||||||
- mailpit-storage:/data
|
|
||||||
environment:
|
|
||||||
- MP_DATABASE=/data/mailpit.db
|
|
||||||
volumes:
|
|
||||||
mailpit-storage:
|
|
||||||
@@ -7,8 +7,8 @@ services:
|
|||||||
POSTGRES_DB: postgres # Default database name
|
POSTGRES_DB: postgres # Default database name
|
||||||
POSTGRES_USER: postgres # Default user
|
POSTGRES_USER: postgres # Default user
|
||||||
POSTGRES_PASSWORD: password # Default password (change for production!)
|
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||||
volumes:
|
# volumes:
|
||||||
- ./config/postgres:/var/lib/postgresql/data
|
# - ./config/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432" # Map host port 5432 to container port 5432
|
- "5432:5432" # Map host port 5432 to container port 5432
|
||||||
restart: no
|
restart: no
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "./server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
+34
-17
@@ -1,24 +1,41 @@
|
|||||||
all: go-build-release
|
all: update-versions go-build-release put-back
|
||||||
|
dev-all: dev-update-versions dev-build dev-clean
|
||||||
# Build with version injection via ldflags
|
|
||||||
# Versions can be passed via: make go-build-release PANGOLIN_VERSION=x.x.x GERBIL_VERSION=x.x.x BADGER_VERSION=x.x.x
|
|
||||||
# Or fetched automatically if not provided (requires curl and jq)
|
|
||||||
|
|
||||||
PANGOLIN_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name')
|
|
||||||
GERBIL_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
|
|
||||||
BADGER_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
|
|
||||||
|
|
||||||
LDFLAGS = -X main.pangolinVersion=$(PANGOLIN_VERSION) \
|
|
||||||
-X main.gerbilVersion=$(GERBIL_VERSION) \
|
|
||||||
-X main.badgerVersion=$(BADGER_VERSION)
|
|
||||||
|
|
||||||
go-build-release:
|
go-build-release:
|
||||||
@echo "Building with versions - Pangolin: $(PANGOLIN_VERSION), Gerbil: $(GERBIL_VERSION), Badger: $(BADGER_VERSION)"
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f bin/installer_linux_amd64
|
rm -f bin/installer_linux_amd64
|
||||||
rm -f bin/installer_linux_arm64
|
rm -f bin/installer_linux_arm64
|
||||||
|
|
||||||
.PHONY: all go-build-release clean
|
update-versions:
|
||||||
|
@echo "Fetching latest versions..."
|
||||||
|
cp main.go main.go.bak && \
|
||||||
|
$(MAKE) dev-update-versions
|
||||||
|
|
||||||
|
put-back:
|
||||||
|
mv main.go.bak main.go
|
||||||
|
|
||||||
|
dev-update-versions:
|
||||||
|
if [ -z "$(tag)" ]; then \
|
||||||
|
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name'); \
|
||||||
|
else \
|
||||||
|
PANGOLIN_VERSION=$(tag); \
|
||||||
|
fi && \
|
||||||
|
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
||||||
|
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||||
|
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||||
|
echo "Updated main.go with latest versions"
|
||||||
|
|
||||||
|
dev-build: go-build-release
|
||||||
|
|
||||||
|
dev-clean:
|
||||||
|
@echo "Restoring version values ..."
|
||||||
|
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \
|
||||||
|
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \
|
||||||
|
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go
|
||||||
|
@echo "Restored version strings in main.go"
|
||||||
|
|||||||
+26
-25
@@ -99,6 +99,11 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) {
|
|||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findPattern finds the start of a pattern in a string
|
||||||
|
func findPattern(s, pattern string) int {
|
||||||
|
return bytes.Index([]byte(s), []byte(pattern))
|
||||||
|
}
|
||||||
|
|
||||||
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||||
// Read source file
|
// Read source file
|
||||||
sourceData, err := os.ReadFile(sourceFile)
|
sourceData, err := os.ReadFile(sourceFile)
|
||||||
@@ -113,19 +118,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse source Docker Compose YAML
|
// Parse source Docker Compose YAML
|
||||||
var sourceCompose map[string]any
|
var sourceCompose map[string]interface{}
|
||||||
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||||
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse destination Docker Compose YAML
|
// Parse destination Docker Compose YAML
|
||||||
var destCompose map[string]any
|
var destCompose map[string]interface{}
|
||||||
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||||
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get services section from source
|
// Get services section from source
|
||||||
sourceServices, ok := sourceCompose["services"].(map[string]any)
|
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("services section not found in source file or has invalid format")
|
return fmt.Errorf("services section not found in source file or has invalid format")
|
||||||
}
|
}
|
||||||
@@ -137,10 +142,10 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get or create services section in destination
|
// Get or create services section in destination
|
||||||
destServices, ok := destCompose["services"].(map[string]any)
|
destServices, ok := destCompose["services"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
// If services section doesn't exist, create it
|
// If services section doesn't exist, create it
|
||||||
destServices = make(map[string]any)
|
destServices = make(map[string]interface{})
|
||||||
destCompose["services"] = destServices
|
destCompose["services"] = destServices
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,21 +187,17 @@ func backupConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
|
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
||||||
buffer := new(bytes.Buffer)
|
buffer := new(bytes.Buffer)
|
||||||
encoder := yaml.NewEncoder(buffer)
|
encoder := yaml.NewEncoder(buffer)
|
||||||
encoder.SetIndent(indent)
|
encoder.SetIndent(indent)
|
||||||
|
|
||||||
if err := encoder.Encode(data); err != nil {
|
err := encoder.Encode(data)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer encoder.Close()
|
||||||
if cerr := encoder.Close(); cerr != nil && err == nil {
|
|
||||||
err = cerr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return buffer.Bytes(), nil
|
return buffer.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ func replaceInFile(filepath, oldStr, newStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Replace the string
|
// Replace the string
|
||||||
newContent := strings.ReplaceAll(string(content), oldStr, newStr)
|
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
||||||
|
|
||||||
// Write the modified content back to the file
|
// Write the modified content back to the file
|
||||||
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||||
@@ -227,28 +228,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse YAML into a generic map
|
// Parse YAML into a generic map
|
||||||
var compose map[string]any
|
var compose map[string]interface{}
|
||||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||||
return fmt.Errorf("error parsing compose file: %w", err)
|
return fmt.Errorf("error parsing compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get services section
|
// Get services section
|
||||||
services, ok := compose["services"].(map[string]any)
|
services, ok := compose["services"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("services section not found or invalid")
|
return fmt.Errorf("services section not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get traefik service
|
// Get traefik service
|
||||||
traefik, ok := services["traefik"].(map[string]any)
|
traefik, ok := services["traefik"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("traefik service not found or invalid")
|
return fmt.Errorf("traefik service not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check volumes
|
// Check volumes
|
||||||
logVolume := "./config/traefik/logs:/var/log/traefik"
|
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||||
var volumes []any
|
var volumes []interface{}
|
||||||
|
|
||||||
if existingVolumes, ok := traefik["volumes"].([]any); ok {
|
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
||||||
// Check if volume already exists
|
// Check if volume already exists
|
||||||
for _, v := range existingVolumes {
|
for _, v := range existingVolumes {
|
||||||
if v.(string) == logVolume {
|
if v.(string) == logVolume {
|
||||||
@@ -294,13 +295,13 @@ func MergeYAML(baseFile, overlayFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse base YAML into a map
|
// Parse base YAML into a map
|
||||||
var baseMap map[string]any
|
var baseMap map[string]interface{}
|
||||||
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||||
return fmt.Errorf("error parsing base YAML: %v", err)
|
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse overlay YAML into a map
|
// Parse overlay YAML into a map
|
||||||
var overlayMap map[string]any
|
var overlayMap map[string]interface{}
|
||||||
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||||
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||||
}
|
}
|
||||||
@@ -323,8 +324,8 @@ func MergeYAML(baseFile, overlayFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mergeMap recursively merges two maps
|
// mergeMap recursively merges two maps
|
||||||
func mergeMap(base, overlay map[string]any) map[string]any {
|
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||||
result := make(map[string]any)
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
// Copy all key-values from base map
|
// Copy all key-values from base map
|
||||||
for k, v := range base {
|
for k, v := range base {
|
||||||
@@ -335,8 +336,8 @@ func mergeMap(base, overlay map[string]any) map[string]any {
|
|||||||
for k, v := range overlay {
|
for k, v := range overlay {
|
||||||
// If both maps have the same key and both values are maps, merge recursively
|
// If both maps have the same key and both values are maps, merge recursively
|
||||||
if baseVal, ok := base[k]; ok {
|
if baseVal, ok := base[k]; ok {
|
||||||
if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap {
|
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
||||||
if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap {
|
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
||||||
result[k] = mergeMap(baseMap, overlayMap)
|
result[k] = mergeMap(baseMap, overlayMap)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,19 +81,11 @@ entryPoints:
|
|||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: "30m"
|
||||||
http3:
|
|
||||||
advertisedPort: 443
|
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
middlewares:
|
middlewares:
|
||||||
- crowdsec@file
|
- crowdsec@file
|
||||||
encodedCharacters:
|
|
||||||
allowEncodedSlash: true
|
|
||||||
allowEncodedQuestionMark: true
|
|
||||||
|
|
||||||
serversTransport:
|
serversTransport:
|
||||||
insecureSkipVerify: true
|
insecureSkipVerify: true
|
||||||
|
|
||||||
ping:
|
|
||||||
entryPoint: "web"
|
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ services:
|
|||||||
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 1g
|
|
||||||
reservations:
|
|
||||||
memory: 256m
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -38,14 +32,15 @@ services:
|
|||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
|
||||||
- 80:80
|
- 80:80
|
||||||
{{end}}
|
{{end}}
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
|
{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ entryPoints:
|
|||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: "30m"
|
||||||
http3:
|
|
||||||
advertisedPort: 443
|
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
|
|||||||
@@ -144,13 +144,12 @@ func installDocker() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startDockerService() error {
|
func startDockerService() error {
|
||||||
switch runtime.GOOS {
|
if runtime.GOOS == "linux" {
|
||||||
case "linux":
|
|
||||||
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
case "darwin":
|
} else if runtime.GOOS == "darwin" {
|
||||||
// On macOS, Docker is usually started via the Docker Desktop application
|
// On macOS, Docker is usually started via the Docker Desktop application
|
||||||
fmt.Println("Please start Docker Desktop manually on macOS.")
|
fmt.Println("Please start Docker Desktop manually on macOS.")
|
||||||
return nil
|
return nil
|
||||||
@@ -303,7 +302,7 @@ func pullContainers(containerType SupportedContainer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startContainers starts the containers using the appropriate command.
|
// startContainers starts the containers using the appropriate command.
|
||||||
@@ -326,7 +325,7 @@ func startContainers(containerType SupportedContainer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopContainers stops the containers using the appropriate command.
|
// stopContainers stops the containers using the appropriate command.
|
||||||
@@ -348,7 +347,7 @@ func stopContainers(containerType SupportedContainer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartContainer restarts a specific container using the appropriate command.
|
// restartContainer restarts a specific container using the appropriate command.
|
||||||
@@ -370,5 +369,5 @@ func restartContainer(container string, containerType SupportedContainer) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-89
@@ -6,13 +6,12 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func installCrowdsec(config Config, installDir string) error {
|
func installCrowdsec(config Config) error {
|
||||||
|
|
||||||
if err := stopContainers(config.InstallationContainerType); err != nil {
|
if err := stopContainers(config.InstallationContainerType); err != nil {
|
||||||
return fmt.Errorf("failed to stop containers: %v", err)
|
return fmt.Errorf("failed to stop containers: %v", err)
|
||||||
@@ -28,20 +27,9 @@ func installCrowdsec(config Config, installDir string) error {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil {
|
os.MkdirAll("config/crowdsec/db", 0755)
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
os.MkdirAll("config/crowdsec/acquis.d", 0755)
|
||||||
os.Exit(1)
|
os.MkdirAll("config/traefik/logs", 0755)
|
||||||
}
|
|
||||||
if err := os.MkdirAll("config/crowdsec/acquis.d", 0755); err != nil {
|
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll("config/traefik/logs", 0755); err != nil {
|
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
setupTraefikLogRotate(installDir)
|
|
||||||
|
|
||||||
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||||
fmt.Printf("Error copying docker service: %v\n", err)
|
fmt.Printf("Error copying docker service: %v\n", err)
|
||||||
@@ -165,34 +153,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse YAML into a generic map
|
// Parse YAML into a generic map
|
||||||
var compose map[string]any
|
var compose map[string]interface{}
|
||||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||||
return fmt.Errorf("error parsing compose file: %w", err)
|
return fmt.Errorf("error parsing compose file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get services section
|
// Get services section
|
||||||
services, ok := compose["services"].(map[string]any)
|
services, ok := compose["services"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("services section not found or invalid")
|
return fmt.Errorf("services section not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get traefik service
|
// Get traefik service
|
||||||
traefik, ok := services["traefik"].(map[string]any)
|
traefik, ok := services["traefik"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("traefik service not found or invalid")
|
return fmt.Errorf("traefik service not found or invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get dependencies
|
// Get dependencies
|
||||||
dependsOn, ok := traefik["depends_on"].(map[string]any)
|
dependsOn, ok := traefik["depends_on"].(map[string]interface{})
|
||||||
if ok {
|
if ok {
|
||||||
// Append the new block for crowdsec
|
// Append the new block for crowdsec
|
||||||
dependsOn["crowdsec"] = map[string]any{
|
dependsOn["crowdsec"] = map[string]interface{}{
|
||||||
"condition": "service_healthy",
|
"condition": "service_healthy",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No dependencies exist, create it
|
// No dependencies exist, create it
|
||||||
traefik["depends_on"] = map[string]any{
|
traefik["depends_on"] = map[string]interface{}{
|
||||||
"crowdsec": map[string]any{
|
"crowdsec": map[string]interface{}{
|
||||||
"condition": "service_healthy",
|
"condition": "service_healthy",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -211,69 +199,3 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
|
|||||||
fmt.Println("Added dependency of crowdsec to traefik")
|
fmt.Println("Added dependency of crowdsec to traefik")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupTraefikLogRotate writes a logrotate config for the Traefik access log
|
|
||||||
// that CrowdSec depends on. This is only needed when CrowdSec is installed
|
|
||||||
// because the default Pangolin install does not enable Traefik access logs.
|
|
||||||
//
|
|
||||||
// copytruncate is used so Traefik does not need to be restarted or sent a
|
|
||||||
// signal after rotation — it keeps writing to the same file descriptor while
|
|
||||||
// the rotated copy is made and the original is truncated in place.
|
|
||||||
func setupTraefikLogRotate(installDir string) {
|
|
||||||
const logrotateDir = "/etc/logrotate.d"
|
|
||||||
const logrotateFile = "/etc/logrotate.d/pangolin-traefik"
|
|
||||||
|
|
||||||
logPath := filepath.Join(installDir, "config/traefik/logs/access.log")
|
|
||||||
|
|
||||||
if os.Geteuid() != 0 {
|
|
||||||
fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.")
|
|
||||||
fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,")
|
|
||||||
fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:")
|
|
||||||
printLogrotateConfig(logPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec.
|
|
||||||
# Generated by the Pangolin installer. Safe to edit.
|
|
||||||
%s {
|
|
||||||
daily
|
|
||||||
rotate 7
|
|
||||||
compress
|
|
||||||
delaycompress
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
copytruncate
|
|
||||||
}
|
|
||||||
`, logPath)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(logrotateDir, 0755); err != nil {
|
|
||||||
fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil {
|
|
||||||
fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err)
|
|
||||||
fmt.Println("[logrotate] Set it up manually:")
|
|
||||||
printLogrotateConfig(logPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile)
|
|
||||||
fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// printLogrotateConfig prints a logrotate config block to stdout so users can
|
|
||||||
// set it up manually when the installer cannot write to /etc.
|
|
||||||
func printLogrotateConfig(logPath string) {
|
|
||||||
fmt.Printf(`
|
|
||||||
%s {
|
|
||||||
daily
|
|
||||||
rotate 7
|
|
||||||
compress
|
|
||||||
delaycompress
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
copytruncate
|
|
||||||
}
|
|
||||||
`, logPath)
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-31
@@ -1,38 +1,10 @@
|
|||||||
module installer
|
module installer
|
||||||
|
|
||||||
go 1.25.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
golang.org/x/term v0.39.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
|
||||||
golang.org/x/term v0.43.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require golang.org/x/sys v0.40.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
golang.org/x/text v0.23.0 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
+4
-77
@@ -1,80 +1,7 @@
|
|||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
|
||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
|
||||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
|
||||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
|
||||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
|
||||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
|
||||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
|
||||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
|
||||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
|
||||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
+66
-182
@@ -1,208 +1,92 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"strings"
|
||||||
"strconv"
|
"syscall"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pangolinTheme is the custom theme using brand colors
|
func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
|
||||||
var pangolinTheme = ThemePangolin()
|
|
||||||
|
|
||||||
// isAccessibleMode checks if we should use accessible mode (simple prompts)
|
|
||||||
// This is true for: non-TTY, TERM=dumb, or ACCESSIBLE env var set
|
|
||||||
func isAccessibleMode() bool {
|
|
||||||
// Check if stdin is not a terminal (piped input, CI, etc.)
|
|
||||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Check for dumb terminal
|
|
||||||
if os.Getenv("TERM") == "dumb" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Check for explicit accessible mode request
|
|
||||||
if os.Getenv("ACCESSIBLE") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAbort checks if the error is a user abort (Ctrl+C) and exits if so
|
|
||||||
func handleAbort(err error) {
|
|
||||||
if err != nil && errors.Is(err, huh.ErrUserAborted) {
|
|
||||||
fmt.Println("\nInstallation cancelled.")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runField runs a single field with the Pangolin theme, handling accessible mode
|
|
||||||
func runField(field huh.Field) error {
|
|
||||||
if isAccessibleMode() {
|
|
||||||
return field.RunAccessible(os.Stdout, os.Stdin)
|
|
||||||
}
|
|
||||||
form := huh.NewForm(huh.NewGroup(field)).WithTheme(pangolinTheme)
|
|
||||||
return form.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
func readString(prompt string, defaultValue string) string {
|
|
||||||
var value string
|
|
||||||
|
|
||||||
title := prompt
|
|
||||||
if defaultValue != "" {
|
if defaultValue != "" {
|
||||||
title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue)
|
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
|
||||||
|
} else {
|
||||||
|
fmt.Print(prompt + ": ")
|
||||||
}
|
}
|
||||||
|
input, _ := reader.ReadString('\n')
|
||||||
input := huh.NewInput().
|
input = strings.TrimSpace(input)
|
||||||
Title(title).
|
if input == "" {
|
||||||
Value(&value)
|
return defaultValue
|
||||||
|
|
||||||
// If no default value, this field is required
|
|
||||||
if defaultValue == "" {
|
|
||||||
input = input.Validate(func(s string) error {
|
|
||||||
if s == "" {
|
|
||||||
return fmt.Errorf("this field is required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return input
|
||||||
err := runField(input)
|
|
||||||
handleAbort(err)
|
|
||||||
|
|
||||||
if value == "" {
|
|
||||||
value = defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print the answer so it remains visible in terminal history (skip in accessible mode as it already shows)
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
fmt.Printf("%s: %s\n", prompt, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readPassword(prompt string) string {
|
func readStringNoDefault(reader *bufio.Reader, prompt string) string {
|
||||||
var value string
|
fmt.Print(prompt + ": ")
|
||||||
|
input, _ := reader.ReadString('\n')
|
||||||
|
return strings.TrimSpace(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||||
|
if term.IsTerminal(int(syscall.Stdin)) {
|
||||||
|
fmt.Print(prompt + ": ")
|
||||||
|
// Read password without echo if we're in a terminal
|
||||||
|
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
input := strings.TrimSpace(string(password))
|
||||||
|
if input == "" {
|
||||||
|
return readPassword(prompt, reader)
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
} else {
|
||||||
|
// Fallback to reading from stdin if not in a terminal
|
||||||
|
return readString(reader, prompt, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||||
|
defaultStr := "no"
|
||||||
|
if defaultValue {
|
||||||
|
defaultStr = "yes"
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
input := huh.NewInput().
|
input := readString(reader, prompt+" (yes/no)", defaultStr)
|
||||||
Title(prompt).
|
lower := strings.ToLower(input)
|
||||||
Value(&value).
|
if lower == "yes" {
|
||||||
EchoMode(huh.EchoModePassword).
|
return true
|
||||||
Validate(func(s string) error {
|
} else if lower == "no" {
|
||||||
if s == "" {
|
return false
|
||||||
return fmt.Errorf("password is required")
|
} else {
|
||||||
}
|
fmt.Println("Please enter 'yes' or 'no'.")
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runField(input)
|
|
||||||
handleAbort(err)
|
|
||||||
|
|
||||||
if value != "" {
|
|
||||||
// Print confirmation without revealing the password
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
fmt.Printf("%s: %s\n", prompt, "********")
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBool(prompt string, defaultValue bool) bool {
|
func readBoolNoDefault(reader *bufio.Reader, prompt string) bool {
|
||||||
var value = defaultValue
|
for {
|
||||||
|
input := readStringNoDefault(reader, prompt+" (yes/no)")
|
||||||
confirm := huh.NewConfirm().
|
lower := strings.ToLower(input)
|
||||||
Title(prompt).
|
if lower == "yes" {
|
||||||
Value(&value).
|
return true
|
||||||
Affirmative("Yes").
|
} else if lower == "no" {
|
||||||
Negative("No")
|
return false
|
||||||
|
} else {
|
||||||
err := runField(confirm)
|
fmt.Println("Please enter 'yes' or 'no'.")
|
||||||
handleAbort(err)
|
|
||||||
|
|
||||||
// Print the answer so it remains visible in terminal history
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
answer := "No"
|
|
||||||
if value {
|
|
||||||
answer = "Yes"
|
|
||||||
}
|
}
|
||||||
fmt.Printf("%s: %s\n", prompt, answer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBoolNoDefault(prompt string) bool {
|
func readInt(reader *bufio.Reader, prompt string, defaultValue int) int {
|
||||||
var value bool
|
input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue))
|
||||||
|
if input == "" {
|
||||||
confirm := huh.NewConfirm().
|
|
||||||
Title(prompt).
|
|
||||||
Value(&value).
|
|
||||||
Affirmative("Yes").
|
|
||||||
Negative("No")
|
|
||||||
|
|
||||||
err := runField(confirm)
|
|
||||||
handleAbort(err)
|
|
||||||
|
|
||||||
// Print the answer so it remains visible in terminal history
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
answer := "No"
|
|
||||||
if value {
|
|
||||||
answer = "Yes"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: %s\n", prompt, answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func readInt(prompt string, defaultValue int) int {
|
|
||||||
var value string
|
|
||||||
|
|
||||||
title := fmt.Sprintf("%s (default: %d)", prompt, defaultValue)
|
|
||||||
|
|
||||||
input := huh.NewInput().
|
|
||||||
Title(title).
|
|
||||||
Value(&value).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := strconv.Atoi(s)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("please enter a valid number")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runField(input)
|
|
||||||
handleAbort(err)
|
|
||||||
|
|
||||||
if value == "" {
|
|
||||||
// Print the answer so it remains visible in terminal history
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
fmt.Printf("%s: %d\n", prompt, defaultValue)
|
|
||||||
}
|
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
value := defaultValue
|
||||||
result, err := strconv.Atoi(value)
|
fmt.Sscanf(input, "%d", &value)
|
||||||
if err != nil {
|
return value
|
||||||
if !isAccessibleMode() {
|
|
||||||
fmt.Printf("%s: %d\n", prompt, defaultValue)
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print the answer so it remains visible in terminal history
|
|
||||||
if !isAccessibleMode() {
|
|
||||||
fmt.Printf("%s: %d\n", prompt, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-220
@@ -1,36 +1,30 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"bufio"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version variables injected at build time via -ldflags
|
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||||
var (
|
|
||||||
pangolinVersion string
|
|
||||||
gerbilVersion string
|
|
||||||
badgerVersion string
|
|
||||||
)
|
|
||||||
|
|
||||||
func loadVersions(config *Config) {
|
func loadVersions(config *Config) {
|
||||||
config.PangolinVersion = pangolinVersion
|
config.PangolinVersion = "replaceme"
|
||||||
config.GerbilVersion = gerbilVersion
|
config.GerbilVersion = "replaceme"
|
||||||
config.BadgerVersion = badgerVersion
|
config.BadgerVersion = "replaceme"
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed config/*
|
//go:embed config/*
|
||||||
@@ -69,9 +63,6 @@ const (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
|
|
||||||
fmt.Println("Welcome to the Pangolin installer!")
|
fmt.Println("Welcome to the Pangolin installer!")
|
||||||
@@ -91,19 +82,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
var alreadyInstalled = false
|
var alreadyInstalled = false
|
||||||
|
|
||||||
// Determine installation directory
|
|
||||||
installDir := findOrSelectInstallDirectory()
|
|
||||||
if err := os.Chdir(installDir); err != nil {
|
|
||||||
fmt.Printf("Error changing to installation directory: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config = collectUserInput()
|
config = collectUserInput(reader)
|
||||||
|
|
||||||
loadVersions(&config)
|
loadVersions(&config)
|
||||||
config.DoCrowdsecInstall = false
|
config.DoCrowdsecInstall = false
|
||||||
@@ -116,10 +102,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil {
|
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||||
fmt.Printf("Error moving docker-compose.yml: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nConfiguration files created successfully!")
|
fmt.Println("\nConfiguration files created successfully!")
|
||||||
|
|
||||||
@@ -134,17 +117,13 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\n=== Starting installation ===")
|
fmt.Println("\n=== Starting installation ===")
|
||||||
|
|
||||||
if readBool("Would you like to install and start the containers?", true) {
|
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||||
|
|
||||||
config.InstallationContainerType = podmanOrDocker()
|
config.InstallationContainerType = podmanOrDocker(reader)
|
||||||
|
|
||||||
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
|
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
|
||||||
if readBool("Docker is not installed. Would you like to install it?", true) {
|
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||||
if err := installDocker(); err != nil {
|
installDocker()
|
||||||
fmt.Printf("Error installing Docker: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to start docker service but ignore errors
|
// try to start docker service but ignore errors
|
||||||
if err := startDockerService(); err != nil {
|
if err := startDockerService(); err != nil {
|
||||||
fmt.Println("Error starting Docker service:", err)
|
fmt.Println("Error starting Docker service:", err)
|
||||||
@@ -153,7 +132,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
||||||
fmt.Println("Waiting for Docker to start...")
|
fmt.Println("Waiting for Docker to start...")
|
||||||
for range 5 {
|
for i := 0; i < 5; i++ {
|
||||||
if isDockerRunning() {
|
if isDockerRunning() {
|
||||||
fmt.Println("Docker is running!")
|
fmt.Println("Docker is running!")
|
||||||
break
|
break
|
||||||
@@ -188,7 +167,7 @@ func main() {
|
|||||||
fmt.Println("\n=== MaxMind Database Update ===")
|
fmt.Println("\n=== MaxMind Database Update ===")
|
||||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||||
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
if readBool(reader, "Would you like to update the MaxMind database to the latest version?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try updating it manually later if needed.")
|
fmt.Println("You can try updating it manually later if needed.")
|
||||||
@@ -196,7 +175,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
||||||
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
if readBool(reader, "Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try downloading it manually later if needed.")
|
fmt.Println("You can try downloading it manually later if needed.")
|
||||||
@@ -210,14 +189,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
|
if !checkIsCrowdsecInstalledInCompose() {
|
||||||
fmt.Println("\n=== CrowdSec Install ===")
|
fmt.Println("\n=== CrowdSec Install ===")
|
||||||
// check if crowdsec is installed
|
// check if crowdsec is installed
|
||||||
if readBool("Would you like to install CrowdSec?", false) {
|
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
||||||
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
||||||
|
|
||||||
// BUG: crowdsec installation will be skipped if the user chooses to install on the first installation.
|
// BUG: crowdsec installation will be skipped if the user chooses to install on the first installation.
|
||||||
if readBool("Are you willing to manage CrowdSec?", false) {
|
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
|
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -246,8 +225,8 @@ func main() {
|
|||||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||||
|
|
||||||
if !readBool("Are these values correct?", true) {
|
if !readBool(reader, "Are these values correct?", true) {
|
||||||
config = collectUserInput()
|
config = collectUserInput(reader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,14 +235,14 @@ func main() {
|
|||||||
if detectedType == Undefined {
|
if detectedType == Undefined {
|
||||||
// If detection fails, prompt the user
|
// If detection fails, prompt the user
|
||||||
fmt.Println("Unable to detect container type from existing installation.")
|
fmt.Println("Unable to detect container type from existing installation.")
|
||||||
config.InstallationContainerType = podmanOrDocker()
|
config.InstallationContainerType = podmanOrDocker(reader)
|
||||||
} else {
|
} else {
|
||||||
config.InstallationContainerType = detectedType
|
config.InstallationContainerType = detectedType
|
||||||
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
|
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.DoCrowdsecInstall = true
|
config.DoCrowdsecInstall = true
|
||||||
err := installCrowdsec(config, installDir)
|
err := installCrowdsec(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error installing CrowdSec: %v\n", err)
|
fmt.Printf("Error installing CrowdSec: %v\n", err)
|
||||||
return
|
return
|
||||||
@@ -298,119 +277,8 @@ func main() {
|
|||||||
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasExistingInstall(dir string) bool {
|
func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||||
configPath := filepath.Join(dir, "config", "config.yml")
|
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||||
_, err := os.Stat(configPath)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOrSelectInstallDirectory() string {
|
|
||||||
const defaultInstallDir = "/opt/pangolin"
|
|
||||||
|
|
||||||
// Get current working directory
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error getting current directory: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Check current directory for existing install
|
|
||||||
if hasExistingInstall(cwd) {
|
|
||||||
fmt.Printf("Found existing Pangolin installation in current directory: %s\n", cwd)
|
|
||||||
return cwd
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check default location (/opt/pangolin) for existing install
|
|
||||||
if cwd != defaultInstallDir && hasExistingInstall(defaultInstallDir) {
|
|
||||||
fmt.Printf("\nFound existing Pangolin installation at: %s\n", defaultInstallDir)
|
|
||||||
if readBool(fmt.Sprintf("Would you like to use the existing installation at %s?", defaultInstallDir), true) {
|
|
||||||
return defaultInstallDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. No existing install found, prompt for installation directory
|
|
||||||
fmt.Println("\n=== Installation Directory ===")
|
|
||||||
fmt.Println("No existing Pangolin installation detected.")
|
|
||||||
|
|
||||||
installDir := readString("Enter the installation directory", defaultInstallDir)
|
|
||||||
|
|
||||||
// Expand ~ to home directory if present
|
|
||||||
if strings.HasPrefix(installDir, "~") {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error getting home directory: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
installDir = filepath.Join(home, installDir[1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to absolute path
|
|
||||||
absPath, err := filepath.Abs(installDir)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error resolving path: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
installDir = absPath
|
|
||||||
|
|
||||||
// Check if directory exists
|
|
||||||
if _, err := os.Stat(installDir); os.IsNotExist(err) {
|
|
||||||
// Directory doesn't exist, create it
|
|
||||||
if readBool(fmt.Sprintf("Directory %s does not exist. Create it?", installDir), true) {
|
|
||||||
if err := os.MkdirAll(installDir, 0755); err != nil {
|
|
||||||
fmt.Printf("Error creating directory: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Created directory: %s\n", installDir)
|
|
||||||
|
|
||||||
// Offer to change ownership if running via sudo
|
|
||||||
changeDirectoryOwnership(installDir)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Installation cancelled.")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Installation directory: %s\n", installDir)
|
|
||||||
return installDir
|
|
||||||
}
|
|
||||||
|
|
||||||
func changeDirectoryOwnership(dir string) {
|
|
||||||
// Check if we're running via sudo by looking for SUDO_USER
|
|
||||||
sudoUser := os.Getenv("SUDO_USER")
|
|
||||||
if sudoUser == "" || os.Geteuid() != 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sudoUID := os.Getenv("SUDO_UID")
|
|
||||||
sudoGID := os.Getenv("SUDO_GID")
|
|
||||||
|
|
||||||
if sudoUID == "" || sudoGID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nRunning as root via sudo (original user: %s)\n", sudoUser)
|
|
||||||
if readBool(fmt.Sprintf("Would you like to change ownership of %s to user '%s'? This makes it easier to manage config files without sudo.", dir, sudoUser), true) {
|
|
||||||
uid, err := strconv.Atoi(sudoUID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Warning: Could not parse SUDO_UID: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
gid, err := strconv.Atoi(sudoGID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Warning: Could not parse SUDO_GID: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Chown(dir, uid, gid); err != nil {
|
|
||||||
fmt.Printf("Warning: Could not change ownership: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Changed ownership of %s to %s\n", dir, sudoUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func podmanOrDocker() SupportedContainer {
|
|
||||||
inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
|
||||||
|
|
||||||
chosenContainer := Docker
|
chosenContainer := Docker
|
||||||
if strings.EqualFold(inputContainer, "docker") {
|
if strings.EqualFold(inputContainer, "docker") {
|
||||||
@@ -422,8 +290,7 @@ func podmanOrDocker() SupportedContainer {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch chosenContainer {
|
if chosenContainer == Podman {
|
||||||
case Podman:
|
|
||||||
if !isPodmanInstalled() {
|
if !isPodmanInstalled() {
|
||||||
fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.")
|
fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -432,7 +299,7 @@ func podmanOrDocker() SupportedContainer {
|
|||||||
if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
|
if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
|
||||||
fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.")
|
fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.")
|
||||||
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
|
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
|
||||||
approved := readBool("The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
|
approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
|
||||||
if approved {
|
if approved {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
fmt.Println("You need to run the installer as root for such a configuration.")
|
fmt.Println("You need to run the installer as root for such a configuration.")
|
||||||
@@ -444,7 +311,7 @@ func podmanOrDocker() SupportedContainer {
|
|||||||
// Linux only.
|
// Linux only.
|
||||||
|
|
||||||
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil {
|
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil {
|
||||||
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
|
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -454,7 +321,7 @@ func podmanOrDocker() SupportedContainer {
|
|||||||
fmt.Println("Unprivileged ports have been configured.")
|
fmt.Println("Unprivileged ports have been configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
case Docker:
|
} else if chosenContainer == Docker {
|
||||||
// check if docker is not installed and the user is root
|
// check if docker is not installed and the user is root
|
||||||
if !isDockerInstalled() {
|
if !isDockerInstalled() {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
@@ -469,7 +336,7 @@ func podmanOrDocker() SupportedContainer {
|
|||||||
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
default:
|
} else {
|
||||||
// This shouldn't happen unless there's a third container runtime.
|
// This shouldn't happen unless there's a third container runtime.
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -477,35 +344,35 @@ func podmanOrDocker() SupportedContainer {
|
|||||||
return chosenContainer
|
return chosenContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectUserInput() Config {
|
func collectUserInput(reader *bufio.Reader) Config {
|
||||||
config := Config{}
|
config := Config{}
|
||||||
|
|
||||||
// Basic configuration
|
// Basic configuration
|
||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
|
|
||||||
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
|
||||||
// Set default dashboard domain after base domain is collected
|
// Set default dashboard domain after base domain is collected
|
||||||
defaultDashboardDomain := ""
|
defaultDashboardDomain := ""
|
||||||
if config.BaseDomain != "" {
|
if config.BaseDomain != "" {
|
||||||
defaultDashboardDomain = "pangolin." + config.BaseDomain
|
defaultDashboardDomain = "pangolin." + config.BaseDomain
|
||||||
}
|
}
|
||||||
config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
||||||
config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "")
|
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||||
config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true)
|
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
|
||||||
|
|
||||||
// Email configuration
|
// Email configuration
|
||||||
fmt.Println("\n=== Email Configuration ===")
|
fmt.Println("\n=== Email Configuration ===")
|
||||||
config.EnableEmail = readBool("Enable email functionality (SMTP)", false)
|
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
|
||||||
|
|
||||||
if config.EnableEmail {
|
if config.EnableEmail {
|
||||||
config.EmailSMTPHost = readString("Enter SMTP host", "")
|
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
|
||||||
config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587)
|
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
|
||||||
config.EmailSMTPUser = readString("Enter SMTP username", "")
|
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
|
||||||
config.EmailSMTPPass = readPassword("Enter SMTP password")
|
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
|
||||||
config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "")
|
config.EmailNoReply = readString(reader, "Enter no-reply email address (often the same as SMTP username)", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -526,8 +393,8 @@ func collectUserInput() Config {
|
|||||||
|
|
||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
|
||||||
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
@@ -538,23 +405,15 @@ func collectUserInput() Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createConfigFiles(config Config) error {
|
func createConfigFiles(config Config) error {
|
||||||
if err := os.MkdirAll("config", 0755); err != nil {
|
os.MkdirAll("config", 0755)
|
||||||
return fmt.Errorf("failed to create config directory: %v", err)
|
os.MkdirAll("config/letsencrypt", 0755)
|
||||||
}
|
os.MkdirAll("config/db", 0755)
|
||||||
if err := os.MkdirAll("config/letsencrypt", 0755); err != nil {
|
os.MkdirAll("config/logs", 0755)
|
||||||
return fmt.Errorf("failed to create letsencrypt directory: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll("config/db", 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create db directory: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll("config/logs", 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create logs directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk through all embedded files
|
// Walk through all embedded files
|
||||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
|
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||||
if walkErr != nil {
|
if err != nil {
|
||||||
return walkErr
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the root fs directory itself
|
// Skip the root fs directory itself
|
||||||
@@ -605,11 +464,7 @@ func createConfigFiles(config Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create %s: %v", path, err)
|
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer outFile.Close()
|
||||||
if cerr := outFile.Close(); cerr != nil && err == nil {
|
|
||||||
err = cerr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Execute template
|
// Execute template
|
||||||
if err := tmpl.Execute(outFile, config); err != nil {
|
if err := tmpl.Execute(outFile, config); err != nil {
|
||||||
@@ -625,26 +480,18 @@ func createConfigFiles(config Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFile(src, dst string) (err error) {
|
func copyFile(src, dst string) error {
|
||||||
source, err := os.Open(src)
|
source, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer source.Close()
|
||||||
if cerr := source.Close(); cerr != nil && err == nil {
|
|
||||||
err = cerr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
destination, err := os.Create(dst)
|
destination, err := os.Create(dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer destination.Close()
|
||||||
if cerr := destination.Close(); cerr != nil && err == nil {
|
|
||||||
err = cerr
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err = io.Copy(destination, source)
|
_, err = io.Copy(destination, source)
|
||||||
return err
|
return err
|
||||||
@@ -715,24 +562,22 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
|||||||
fmt.Println("To get your setup token, you need to:")
|
fmt.Println("To get your setup token, you need to:")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("1. Start the containers")
|
fmt.Println("1. Start the containers")
|
||||||
switch containerType {
|
if containerType == Docker {
|
||||||
case Docker:
|
|
||||||
fmt.Println(" docker compose up -d")
|
fmt.Println(" docker compose up -d")
|
||||||
case Podman:
|
} else if containerType == Podman {
|
||||||
fmt.Println(" podman-compose up -d")
|
fmt.Println(" podman-compose up -d")
|
||||||
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
|
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("3. Check the container logs for the setup token")
|
fmt.Println("3. Check the container logs for the setup token")
|
||||||
switch containerType {
|
if containerType == Docker {
|
||||||
case Docker:
|
|
||||||
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||||
case Podman:
|
} else if containerType == Podman {
|
||||||
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||||
|
} else {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println("4. Look for output like")
|
fmt.Println("4. Look for output like")
|
||||||
fmt.Println(" === SETUP TOKEN GENERATED ===")
|
fmt.Println(" === SETUP TOKEN GENERATED ===")
|
||||||
@@ -756,6 +601,32 @@ func generateRandomSecretKey() string {
|
|||||||
return base64.StdEncoding.EncodeToString(secret)
|
return base64.StdEncoding.EncodeToString(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPublicIP() string {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("https://ifconfig.io/ip")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := strings.TrimSpace(string(body))
|
||||||
|
|
||||||
|
// Validate that it's a valid IP address
|
||||||
|
if net.ParseIP(ip) != nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Run external commands with stdio/stderr attached.
|
// Run external commands with stdio/stderr attached.
|
||||||
func run(name string, args ...string) error {
|
func run(name string, args ...string) error {
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
@@ -768,7 +639,10 @@ func checkPortsAvailable(port int) error {
|
|||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf(":%d", port)
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err)
|
return fmt.Errorf(
|
||||||
|
"ERROR: port %d is occupied or cannot be bound: %w\n\n",
|
||||||
|
port, err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if closeErr := ln.Close(); closeErr != nil {
|
if closeErr := ln.Close(); closeErr != nil {
|
||||||
fmt.Fprintf(os.Stderr,
|
fmt.Fprintf(os.Stderr,
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pangolin brand colors (converted from oklch to hex)
|
|
||||||
var (
|
|
||||||
// Primary orange/amber - oklch(0.6717 0.1946 41.93)
|
|
||||||
primaryColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}
|
|
||||||
// Muted foreground
|
|
||||||
mutedColor = lipgloss.AdaptiveColor{Light: "#737373", Dark: "#A3A3A3"}
|
|
||||||
// Success green
|
|
||||||
successColor = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"}
|
|
||||||
// Error red - oklch(0.577 0.245 27.325)
|
|
||||||
errorColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"}
|
|
||||||
// Normal text
|
|
||||||
normalFg = lipgloss.AdaptiveColor{Light: "#171717", Dark: "#FAFAFA"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// ThemePangolin returns a huh theme using Pangolin brand colors
|
|
||||||
func ThemePangolin() *huh.Theme {
|
|
||||||
t := huh.ThemeBase()
|
|
||||||
|
|
||||||
// Focused state styles
|
|
||||||
t.Focused.Base = t.Focused.Base.BorderForeground(primaryColor)
|
|
||||||
t.Focused.Title = t.Focused.Title.Foreground(primaryColor).Bold(true)
|
|
||||||
t.Focused.Description = t.Focused.Description.Foreground(mutedColor)
|
|
||||||
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errorColor)
|
|
||||||
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errorColor)
|
|
||||||
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor)
|
|
||||||
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(primaryColor)
|
|
||||||
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(primaryColor)
|
|
||||||
t.Focused.Option = t.Focused.Option.Foreground(normalFg)
|
|
||||||
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(primaryColor)
|
|
||||||
t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(successColor).SetString("✓ ")
|
|
||||||
t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(mutedColor).SetString(" ")
|
|
||||||
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(primaryColor)
|
|
||||||
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#404040"})
|
|
||||||
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primaryColor)
|
|
||||||
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(primaryColor)
|
|
||||||
|
|
||||||
// Blurred state inherits from focused but with hidden border
|
|
||||||
t.Blurred = t.Focused
|
|
||||||
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
|
|
||||||
t.Blurred.Title = t.Blurred.Title.Foreground(mutedColor).Bold(false)
|
|
||||||
t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(mutedColor)
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# --- Configuration ---
|
|
||||||
# The header text to be added to the files.
|
|
||||||
HEADER_TEXT = """/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
"""
|
|
||||||
|
|
||||||
HEADER_NORMALIZED = HEADER_TEXT.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def extract_leading_block_comment(content):
|
|
||||||
"""
|
|
||||||
If the file content begins with a /* ... */ block comment, return the
|
|
||||||
full text of that comment (including the delimiters) and the index at
|
|
||||||
which the rest of the file starts (after any trailing newlines).
|
|
||||||
Returns (None, 0) when no such comment is found.
|
|
||||||
"""
|
|
||||||
stripped = content.lstrip()
|
|
||||||
if not stripped.startswith('/*'):
|
|
||||||
return None, 0
|
|
||||||
|
|
||||||
# Account for any leading whitespace before the comment
|
|
||||||
comment_start = content.index('/*')
|
|
||||||
end_marker = content.find('*/', comment_start + 2)
|
|
||||||
if end_marker == -1:
|
|
||||||
return None, 0
|
|
||||||
|
|
||||||
comment_end = end_marker + 2 # position just after '*/'
|
|
||||||
comment_text = content[comment_start:comment_end].strip()
|
|
||||||
|
|
||||||
# Advance past any whitespace / newlines that follow the closing */
|
|
||||||
rest_start = comment_end
|
|
||||||
while rest_start < len(content) and content[rest_start] in '\n\r':
|
|
||||||
rest_start += 1
|
|
||||||
|
|
||||||
return comment_text, rest_start
|
|
||||||
|
|
||||||
|
|
||||||
def should_add_header(file_path):
|
|
||||||
"""
|
|
||||||
Checks if a file should receive the commercial license header.
|
|
||||||
Returns True if 'server/private' is in the path.
|
|
||||||
"""
|
|
||||||
if 'server/private' in file_path.lower():
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def process_directory(root_dir):
|
|
||||||
"""
|
|
||||||
Recursively scans a directory and adds/replaces/removes headers in
|
|
||||||
qualifying .ts or .tsx files, skipping any 'node_modules' directories.
|
|
||||||
"""
|
|
||||||
print(f"Scanning directory: {root_dir}")
|
|
||||||
files_processed = 0
|
|
||||||
files_modified = 0
|
|
||||||
|
|
||||||
for root, dirs, files in os.walk(root_dir):
|
|
||||||
# Exclude 'node_modules' directories from the scan.
|
|
||||||
if 'node_modules' in dirs:
|
|
||||||
dirs.remove('node_modules')
|
|
||||||
|
|
||||||
for file in files:
|
|
||||||
if not (file.endswith('.ts') or file.endswith('.tsx')):
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_path = os.path.join(root, file)
|
|
||||||
files_processed += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
original_content = f.read()
|
|
||||||
|
|
||||||
existing_comment, body_start = extract_leading_block_comment(
|
|
||||||
original_content
|
|
||||||
)
|
|
||||||
has_any_header = existing_comment is not None
|
|
||||||
has_correct_header = existing_comment == HEADER_NORMALIZED
|
|
||||||
|
|
||||||
body = original_content[body_start:] if has_any_header else original_content
|
|
||||||
|
|
||||||
if should_add_header(file_path):
|
|
||||||
if has_correct_header:
|
|
||||||
print(f"Header up-to-date: {file_path}")
|
|
||||||
else:
|
|
||||||
# Either no header exists or the header is outdated - write
|
|
||||||
# the correct one.
|
|
||||||
action = "Replaced header in" if has_any_header else "Added header to"
|
|
||||||
new_content = HEADER_NORMALIZED + '\n\n' + body
|
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(new_content)
|
|
||||||
print(f"{action}: {file_path}")
|
|
||||||
files_modified += 1
|
|
||||||
else:
|
|
||||||
if has_any_header:
|
|
||||||
# Remove the header - it shouldn't be here.
|
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(body)
|
|
||||||
print(f"Removed header from: {file_path}")
|
|
||||||
files_modified += 1
|
|
||||||
else:
|
|
||||||
print(f"No header needed: {file_path}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error processing file {file_path}: {e}")
|
|
||||||
|
|
||||||
print("\n--- Scan Complete ---")
|
|
||||||
print(f"Total .ts or .tsx files found: {files_processed}")
|
|
||||||
print(f"Files modified (added/replaced/removed): {files_modified}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Get the target directory from the command line arguments.
|
|
||||||
# If no directory is provided, it uses the current directory ('.').
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
target_directory = sys.argv[1]
|
|
||||||
else:
|
|
||||||
target_directory = '.' # Default to current directory
|
|
||||||
|
|
||||||
if not os.path.isdir(target_directory):
|
|
||||||
print(f"Error: Directory '{target_directory}' not found.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
process_directory(os.path.abspath(target_directory))
|
|
||||||
+21
-644
File diff suppressed because it is too large
Load Diff
+20
-643
File diff suppressed because it is too large
Load Diff
+43
-666
File diff suppressed because it is too large
Load Diff
+43
-719
File diff suppressed because it is too large
Load Diff
+21
-644
File diff suppressed because it is too large
Load Diff
+21
-644
File diff suppressed because it is too large
Load Diff
+63
-686
File diff suppressed because it is too large
Load Diff
+21
-644
File diff suppressed because it is too large
Load Diff
+20
-643
File diff suppressed because it is too large
Load Diff
+20
-643
File diff suppressed because it is too large
Load Diff
+20
-643
File diff suppressed because it is too large
Load Diff
+21
-644
File diff suppressed because it is too large
Load Diff
+23
-646
File diff suppressed because it is too large
Load Diff
+21
-644
File diff suppressed because it is too large
Load Diff
+19
-642
@@ -1,8 +1,4 @@
|
|||||||
{
|
{
|
||||||
"contactSalesEnable": "联系销售以启用此功能。",
|
|
||||||
"contactSalesBookDemo": "预订演示",
|
|
||||||
"contactSalesOr": "或",
|
|
||||||
"contactSalesContactUs": "联系我们",
|
|
||||||
"setupCreate": "创建组织、站点和资源",
|
"setupCreate": "创建组织、站点和资源",
|
||||||
"headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。",
|
"headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。",
|
||||||
"headerAuthCompatibility": "扩展兼容性",
|
"headerAuthCompatibility": "扩展兼容性",
|
||||||
@@ -23,18 +19,6 @@
|
|||||||
"componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。",
|
"componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。",
|
||||||
"dismiss": "忽略",
|
"dismiss": "忽略",
|
||||||
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
|
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
|
||||||
"trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。",
|
|
||||||
"trialBannerExpired": "您的试用已到期。立即升级以恢复访问。",
|
|
||||||
"billingTrialBannerTitle": "免费试用激活中",
|
|
||||||
"billingTrialBannerDescription": "您目前正在商用层进行免费试用。试用结束后,您的账户将自动回到基础层功能和限制。可随时升级以保持当前计划的功能访问。",
|
|
||||||
"billingTrialBannerUpgrade": "立即升级",
|
|
||||||
"billingTrialBadge": "免费试用",
|
|
||||||
"trialActive": "免费试用中",
|
|
||||||
"trialExpired": "试用到期",
|
|
||||||
"trialHasEnded": "您的试用已结束。",
|
|
||||||
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
|
|
||||||
"trialDaysLeftShort": "试用期剩余 {days} 天",
|
|
||||||
"trialGoToBilling": "转到账单页面",
|
|
||||||
"subscriptionViolationViewBilling": "查看计费",
|
"subscriptionViolationViewBilling": "查看计费",
|
||||||
"componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。",
|
"componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。",
|
||||||
"componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。",
|
"componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。",
|
||||||
@@ -97,8 +81,6 @@
|
|||||||
"siteConfirmCopy": "我已经复制了配置信息",
|
"siteConfirmCopy": "我已经复制了配置信息",
|
||||||
"searchSitesProgress": "搜索站点...",
|
"searchSitesProgress": "搜索站点...",
|
||||||
"siteAdd": "添加站点",
|
"siteAdd": "添加站点",
|
||||||
"sitesTableViewPublicResources": "查看公共资源",
|
|
||||||
"sitesTableViewPrivateResources": "查看私有资源",
|
|
||||||
"siteInstallNewt": "安装 Newt",
|
"siteInstallNewt": "安装 Newt",
|
||||||
"siteInstallNewtDescription": "在您的系统中运行 Newt",
|
"siteInstallNewtDescription": "在您的系统中运行 Newt",
|
||||||
"WgConfiguration": "WireGuard 配置",
|
"WgConfiguration": "WireGuard 配置",
|
||||||
@@ -116,21 +98,6 @@
|
|||||||
"siteUpdatedDescription": "网站已更新。",
|
"siteUpdatedDescription": "网站已更新。",
|
||||||
"siteGeneralDescription": "配置此站点的常规设置",
|
"siteGeneralDescription": "配置此站点的常规设置",
|
||||||
"siteSettingDescription": "配置站点设置",
|
"siteSettingDescription": "配置站点设置",
|
||||||
"siteResourcesTab": "资源",
|
|
||||||
"siteResourcesNoneOnSite": "此站点尚无公开或私人资源。",
|
|
||||||
"siteResourcesSectionPublic": "公共资源",
|
|
||||||
"siteResourcesSectionPrivate": "私有资源",
|
|
||||||
"siteResourcesSectionPublicDescription": "通过域名或端口公开的资源。",
|
|
||||||
"siteResourcesSectionPrivateDescription": "通过站点可在您的私有网络上访问的资源。",
|
|
||||||
"siteResourcesViewAllPublic": "查看所有资源",
|
|
||||||
"siteResourcesViewAllPrivate": "查看所有资源",
|
|
||||||
"siteResourcesDialogDescription": "此站点的公开和私有资源概览。",
|
|
||||||
"siteResourcesShowMore": "显示更多",
|
|
||||||
"siteResourcesPermissionDenied": "您无权列出这些资源。",
|
|
||||||
"siteResourcesEmptyPublic": "尚无针对该站点的公共资源。",
|
|
||||||
"siteResourcesEmptyPrivate": "尚无与此站点关联的私有资源。",
|
|
||||||
"siteResourcesHowToAccess": "如何访问",
|
|
||||||
"siteResourcesTargetsOnSite": "此站点上的目标",
|
|
||||||
"siteSetting": "{siteName} 设置",
|
"siteSetting": "{siteName} 设置",
|
||||||
"siteNewtTunnel": "新站点 (推荐)",
|
"siteNewtTunnel": "新站点 (推荐)",
|
||||||
"siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。",
|
"siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。",
|
||||||
@@ -156,10 +123,6 @@
|
|||||||
"shareErrorDeleteMessage": "删除链接时出错",
|
"shareErrorDeleteMessage": "删除链接时出错",
|
||||||
"shareDeleted": "链接已删除",
|
"shareDeleted": "链接已删除",
|
||||||
"shareDeletedDescription": "链接已删除",
|
"shareDeletedDescription": "链接已删除",
|
||||||
"shareDelete": "删除共享链接",
|
|
||||||
"shareDeleteConfirm": "确认删除共享链接",
|
|
||||||
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
|
|
||||||
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
|
|
||||||
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
|
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
|
||||||
"accessToken": "访问令牌",
|
"accessToken": "访问令牌",
|
||||||
"usageExamples": "用法示例",
|
"usageExamples": "用法示例",
|
||||||
@@ -185,11 +148,6 @@
|
|||||||
"createLink": "创建链接",
|
"createLink": "创建链接",
|
||||||
"resourcesNotFound": "找不到资源",
|
"resourcesNotFound": "找不到资源",
|
||||||
"resourceSearch": "搜索资源",
|
"resourceSearch": "搜索资源",
|
||||||
"machineSearch": "搜索机",
|
|
||||||
"machinesSearch": "搜索机器客户端...",
|
|
||||||
"machineNotFound": "未找到任何机",
|
|
||||||
"userDeviceSearch": "搜索用户设备",
|
|
||||||
"userDevicesSearch": "搜索用户设备...",
|
|
||||||
"openMenu": "打开菜单",
|
"openMenu": "打开菜单",
|
||||||
"resource": "资源",
|
"resource": "资源",
|
||||||
"title": "标题",
|
"title": "标题",
|
||||||
@@ -217,7 +175,6 @@
|
|||||||
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
|
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
|
||||||
"resourceRaw": "TCP/UDP 资源",
|
"resourceRaw": "TCP/UDP 资源",
|
||||||
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
|
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
|
||||||
"resourceRawDescriptionCloud": "正在使用端口号使用 TCP/UDP 代理请求。需要站点连接到远程节点。",
|
|
||||||
"resourceCreate": "创建资源",
|
"resourceCreate": "创建资源",
|
||||||
"resourceCreateDescription": "按照下面的步骤创建新资源",
|
"resourceCreateDescription": "按照下面的步骤创建新资源",
|
||||||
"resourceSeeAll": "查看所有资源",
|
"resourceSeeAll": "查看所有资源",
|
||||||
@@ -304,11 +261,8 @@
|
|||||||
"orgMissing": "缺少组织 ID",
|
"orgMissing": "缺少组织 ID",
|
||||||
"orgMissingMessage": "没有组织ID,无法重新生成邀请。",
|
"orgMissingMessage": "没有组织ID,无法重新生成邀请。",
|
||||||
"accessUsersManage": "管理用户",
|
"accessUsersManage": "管理用户",
|
||||||
"accessUserManage": "管理用户",
|
|
||||||
"accessUsersDescription": "邀请和管理访问此组织的用户",
|
"accessUsersDescription": "邀请和管理访问此组织的用户",
|
||||||
"accessUsersSearch": "搜索用户...",
|
"accessUsersSearch": "搜索用户...",
|
||||||
"accessUsersRoleFilterCount": "{count, plural, other {# 角色}}",
|
|
||||||
"accessUsersRoleFilterClear": "清除角色过滤器",
|
|
||||||
"accessUserCreate": "创建用户",
|
"accessUserCreate": "创建用户",
|
||||||
"accessUserRemove": "删除用户",
|
"accessUserRemove": "删除用户",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
@@ -368,54 +322,6 @@
|
|||||||
"apiKeysDelete": "删除 API 密钥",
|
"apiKeysDelete": "删除 API 密钥",
|
||||||
"apiKeysManage": "管理 API 密钥",
|
"apiKeysManage": "管理 API 密钥",
|
||||||
"apiKeysDescription": "API 密钥用于认证集成 API",
|
"apiKeysDescription": "API 密钥用于认证集成 API",
|
||||||
"provisioningKeysTitle": "置备密钥",
|
|
||||||
"provisioningKeysManage": "管理置备键",
|
|
||||||
"provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。",
|
|
||||||
"provisioningManage": "置备中",
|
|
||||||
"provisioningDescription": "管理预配键和审查等待批准的站点。",
|
|
||||||
"pendingSites": "待定站点",
|
|
||||||
"siteApproveSuccess": "站点批准成功",
|
|
||||||
"siteApproveError": "批准站点出错",
|
|
||||||
"provisioningKeys": "置备键",
|
|
||||||
"searchProvisioningKeys": "搜索配备密钥...",
|
|
||||||
"provisioningKeysAdd": "生成置备键",
|
|
||||||
"provisioningKeysErrorDelete": "删除预配键时出错",
|
|
||||||
"provisioningKeysErrorDeleteMessage": "删除预配键时出错",
|
|
||||||
"provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?",
|
|
||||||
"provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。",
|
|
||||||
"provisioningKeysDeleteConfirm": "确认删除置备键",
|
|
||||||
"provisioningKeysDelete": "删除置备键",
|
|
||||||
"provisioningKeysCreate": "生成置备键",
|
|
||||||
"provisioningKeysCreateDescription": "为组织生成一个新的预置密钥",
|
|
||||||
"provisioningKeysSeeAll": "查看所有预配键",
|
|
||||||
"provisioningKeysSave": "保存预配键",
|
|
||||||
"provisioningKeysSaveDescription": "您只能看到一次。复制它到一个安全的地方。",
|
|
||||||
"provisioningKeysErrorCreate": "创建预配键时出错",
|
|
||||||
"provisioningKeysList": "新建预配键",
|
|
||||||
"provisioningKeysMaxBatchSize": "最大批量大小",
|
|
||||||
"provisioningKeysUnlimitedBatchSize": "无限批量大小(无限制)",
|
|
||||||
"provisioningKeysMaxBatchUnlimited": "无限制",
|
|
||||||
"provisioningKeysMaxBatchSizeInvalid": "输入一个有效的最大批处理大小(1-1,000,000)。",
|
|
||||||
"provisioningKeysValidUntil": "有效期至",
|
|
||||||
"provisioningKeysValidUntilHint": "留空为无过期。",
|
|
||||||
"provisioningKeysValidUntilInvalid": "输入一个有效的日期和时间。",
|
|
||||||
"provisioningKeysNumUsed": "使用的时间",
|
|
||||||
"provisioningKeysLastUsed": "上次使用",
|
|
||||||
"provisioningKeysNoExpiry": "没有过期",
|
|
||||||
"provisioningKeysNeverUsed": "永不过期",
|
|
||||||
"provisioningKeysEdit": "编辑置备键",
|
|
||||||
"provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。",
|
|
||||||
"provisioningKeysApproveNewSites": "批准新站点",
|
|
||||||
"provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的站点。",
|
|
||||||
"provisioningKeysUpdateError": "更新预配键时出错",
|
|
||||||
"provisioningKeysUpdated": "置备密钥已更新",
|
|
||||||
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
|
||||||
"provisioningKeysBannerTitle": "站点置备密钥",
|
|
||||||
"provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
|
|
||||||
"provisioningKeysBannerButtonText": "了解更多",
|
|
||||||
"pendingSitesBannerTitle": "待定站点",
|
|
||||||
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
|
|
||||||
"pendingSitesBannerButtonText": "了解更多",
|
|
||||||
"apiKeysSettings": "{apiKeyName} 设置",
|
"apiKeysSettings": "{apiKeyName} 设置",
|
||||||
"userTitle": "管理所有用户",
|
"userTitle": "管理所有用户",
|
||||||
"userDescription": "查看和管理系统中的所有用户",
|
"userDescription": "查看和管理系统中的所有用户",
|
||||||
@@ -445,10 +351,6 @@
|
|||||||
"licenseErrorKeyActivate": "激活许可证密钥失败",
|
"licenseErrorKeyActivate": "激活许可证密钥失败",
|
||||||
"licenseErrorKeyActivateDescription": "激活许可证密钥时出错。",
|
"licenseErrorKeyActivateDescription": "激活许可证密钥时出错。",
|
||||||
"licenseAbout": "关于许可协议",
|
"licenseAbout": "关于许可协议",
|
||||||
"licenseBannerTitle": "启用您的企业许可证",
|
|
||||||
"licenseBannerDescription": "为您自行托管的Pangolin实例解锁企业功能。购买许可证密钥以激活高级功能,然后在下方添加。",
|
|
||||||
"licenseBannerGetLicense": "获取许可证",
|
|
||||||
"licenseBannerViewDocs": "查看文档",
|
|
||||||
"communityEdition": "社区版",
|
"communityEdition": "社区版",
|
||||||
"licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。",
|
"licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。",
|
||||||
"licenseKeyActivated": "授权密钥已激活",
|
"licenseKeyActivated": "授权密钥已激活",
|
||||||
@@ -527,12 +429,6 @@
|
|||||||
"userMessageOrgRemove": "一旦删除,这个用户将不再能够访问组织。 你总是可以稍后重新邀请他们,但他们需要再次接受邀请。",
|
"userMessageOrgRemove": "一旦删除,这个用户将不再能够访问组织。 你总是可以稍后重新邀请他们,但他们需要再次接受邀请。",
|
||||||
"userRemoveOrgConfirm": "确认删除用户",
|
"userRemoveOrgConfirm": "确认删除用户",
|
||||||
"userRemoveOrg": "从组织中删除用户",
|
"userRemoveOrg": "从组织中删除用户",
|
||||||
"userQuestionOrgRemoveSelf": "你确定要将自己从这个组织中移除吗?",
|
|
||||||
"userMessageOrgRemoveSelf": "你将立即失去访问权限。管理员稍后可以再次邀请你,但你需要接受新的邀请。",
|
|
||||||
"userRemoveOrgConfirmSelf": "确认删除我自己",
|
|
||||||
"userRemoveOrgSelf": "将自己从组织中移除",
|
|
||||||
"userRemoveOrgSelfWarning": "你将立即失去对此组织的访问权限。",
|
|
||||||
"userRemoveOrgConfirmPhraseSelf": "从组织中移除我自己",
|
|
||||||
"users": "用户",
|
"users": "用户",
|
||||||
"accessRoleMember": "成员",
|
"accessRoleMember": "成员",
|
||||||
"accessRoleOwner": "所有者",
|
"accessRoleOwner": "所有者",
|
||||||
@@ -541,11 +437,6 @@
|
|||||||
"emailInvalid": "无效的电子邮件地址",
|
"emailInvalid": "无效的电子邮件地址",
|
||||||
"inviteValidityDuration": "请选择持续时间",
|
"inviteValidityDuration": "请选择持续时间",
|
||||||
"accessRoleSelectPlease": "请选择一个角色",
|
"accessRoleSelectPlease": "请选择一个角色",
|
||||||
"removeOwnAdminRoleConfirmTitle": "移除你的管理员权限?",
|
|
||||||
"removeOwnAdminRoleConfirmDescription": "保存后,你将不再拥有该组织的管理员权限。如果需要,其他管理员可以恢复访问。",
|
|
||||||
"removeOwnAdminRoleConfirmButton": "移除我的管理员访问权限",
|
|
||||||
"removeOwnAdminRoleConfirmPhrase": "移除我的管理员访问",
|
|
||||||
"ownerMustRetainAdminRole": "组织所有者必须保留至少一个管理员角色。",
|
|
||||||
"usernameRequired": "必须输入用户名",
|
"usernameRequired": "必须输入用户名",
|
||||||
"idpSelectPlease": "请选择身份提供商",
|
"idpSelectPlease": "请选择身份提供商",
|
||||||
"idpGenericOidc": "通用的 OAuth2/OIDC 提供商。",
|
"idpGenericOidc": "通用的 OAuth2/OIDC 提供商。",
|
||||||
@@ -617,12 +508,9 @@
|
|||||||
"userSaved": "用户已保存",
|
"userSaved": "用户已保存",
|
||||||
"userSavedDescription": "用户已更新。",
|
"userSavedDescription": "用户已更新。",
|
||||||
"autoProvisioned": "自动设置",
|
"autoProvisioned": "自动设置",
|
||||||
"autoProvisionSettings": "自动提供设置",
|
|
||||||
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
|
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
|
||||||
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
|
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
|
||||||
"accessControlsSubmit": "保存访问控制",
|
"accessControlsSubmit": "保存访问控制",
|
||||||
"singleRolePerUserPlanNotice": "您的计划仅支持每个用户一个角色。",
|
|
||||||
"singleRolePerUserEditionNotice": "此版本仅支持每个用户一个角色。",
|
|
||||||
"roles": "角色",
|
"roles": "角色",
|
||||||
"accessUsersRoles": "管理用户和角色",
|
"accessUsersRoles": "管理用户和角色",
|
||||||
"accessUsersRolesDescription": "邀请用户加入角色来管理访问组织",
|
"accessUsersRolesDescription": "邀请用户加入角色来管理访问组织",
|
||||||
@@ -630,7 +518,7 @@
|
|||||||
"createdAt": "创建于",
|
"createdAt": "创建于",
|
||||||
"proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。",
|
"proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。",
|
||||||
"proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。",
|
"proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。",
|
||||||
"proxyEnableSSL": "启用 TLS",
|
"proxyEnableSSL": "启用 SSL",
|
||||||
"proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。",
|
"proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"configureTarget": "配置目标",
|
"configureTarget": "配置目标",
|
||||||
@@ -673,15 +561,12 @@
|
|||||||
"targetNoOneDescription": "在上面添加多个目标将启用负载平衡。",
|
"targetNoOneDescription": "在上面添加多个目标将启用负载平衡。",
|
||||||
"targetsSubmit": "保存目标",
|
"targetsSubmit": "保存目标",
|
||||||
"addTarget": "添加目标",
|
"addTarget": "添加目标",
|
||||||
"proxyMultiSiteRoundRobinNodeHelp": "轮询路由在未连接到相同节点的站点之间将不起作用,但故障转移会生效。",
|
|
||||||
"targetErrorInvalidIp": "无效的 IP 地址",
|
"targetErrorInvalidIp": "无效的 IP 地址",
|
||||||
"targetErrorInvalidIpDescription": "请输入有效的IP地址或主机名",
|
"targetErrorInvalidIpDescription": "请输入有效的IP地址或主机名",
|
||||||
"targetErrorInvalidPort": "无效的端口",
|
"targetErrorInvalidPort": "无效的端口",
|
||||||
"targetErrorInvalidPortDescription": "请输入有效的端口号",
|
"targetErrorInvalidPortDescription": "请输入有效的端口号",
|
||||||
"targetErrorNoSite": "没有选择站点",
|
"targetErrorNoSite": "没有选择站点",
|
||||||
"targetErrorNoSiteDescription": "请选择目标站点",
|
"targetErrorNoSiteDescription": "请选择目标站点",
|
||||||
"targetTargetsCleared": "目标已清除",
|
|
||||||
"targetTargetsClearedDescription": "所有目标已从此资源中移除",
|
|
||||||
"targetCreated": "目标已创建",
|
"targetCreated": "目标已创建",
|
||||||
"targetCreatedDescription": "目标已成功创建",
|
"targetCreatedDescription": "目标已成功创建",
|
||||||
"targetErrorCreate": "创建目标失败",
|
"targetErrorCreate": "创建目标失败",
|
||||||
@@ -764,7 +649,7 @@
|
|||||||
"resourcesUsersRolesAccess": "基于用户和角色的访问控制",
|
"resourcesUsersRolesAccess": "基于用户和角色的访问控制",
|
||||||
"resourcesErrorUpdate": "切换资源失败",
|
"resourcesErrorUpdate": "切换资源失败",
|
||||||
"resourcesErrorUpdateDescription": "更新资源时出错",
|
"resourcesErrorUpdateDescription": "更新资源时出错",
|
||||||
"access": "访问权限",
|
"access": "Access",
|
||||||
"accessControl": "访问控制",
|
"accessControl": "访问控制",
|
||||||
"shareLink": "{resource} 的分享链接",
|
"shareLink": "{resource} 的分享链接",
|
||||||
"resourceSelect": "选择资源",
|
"resourceSelect": "选择资源",
|
||||||
@@ -783,7 +668,6 @@
|
|||||||
"newtEndpoint": "Endpoint",
|
"newtEndpoint": "Endpoint",
|
||||||
"newtId": "ID",
|
"newtId": "ID",
|
||||||
"newtSecretKey": "密钥",
|
"newtSecretKey": "密钥",
|
||||||
"newtVersion": "版本",
|
|
||||||
"architecture": "架构",
|
"architecture": "架构",
|
||||||
"sites": "站点",
|
"sites": "站点",
|
||||||
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
|
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
|
||||||
@@ -951,7 +835,6 @@
|
|||||||
"idpDisplayName": "此身份提供商的显示名称",
|
"idpDisplayName": "此身份提供商的显示名称",
|
||||||
"idpAutoProvisionUsers": "自动提供用户",
|
"idpAutoProvisionUsers": "自动提供用户",
|
||||||
"idpAutoProvisionUsersDescription": "如果启用,用户将在首次登录时自动在系统中创建,并且能够映射用户到角色和组织。",
|
"idpAutoProvisionUsersDescription": "如果启用,用户将在首次登录时自动在系统中创建,并且能够映射用户到角色和组织。",
|
||||||
"idpAutoProvisionConfigureAfterCreate": "您可以在创建身份提供者后配置自动配置设置。",
|
|
||||||
"licenseBadge": "EE",
|
"licenseBadge": "EE",
|
||||||
"idpType": "提供者类型",
|
"idpType": "提供者类型",
|
||||||
"idpTypeDescription": "选择您想要配置的身份提供者类型",
|
"idpTypeDescription": "选择您想要配置的身份提供者类型",
|
||||||
@@ -1003,7 +886,7 @@
|
|||||||
"defaultMappingsRole": "默认角色映射",
|
"defaultMappingsRole": "默认角色映射",
|
||||||
"defaultMappingsRoleDescription": "此表达式的结果必须返回组织中定义的角色名称作为字符串。",
|
"defaultMappingsRoleDescription": "此表达式的结果必须返回组织中定义的角色名称作为字符串。",
|
||||||
"defaultMappingsOrg": "默认组织映射",
|
"defaultMappingsOrg": "默认组织映射",
|
||||||
"defaultMappingsOrgDescription": "设置时,此表达式必须返回组织ID或true才能让用户访问该组织。如果未设置,定义角色映射就足够了:只要在组织内可以为用户找出有效角色映射,用户就被允许进入。",
|
"defaultMappingsOrgDescription": "此表达式必须返回 组织ID 或 true 才能允许用户访问组织。",
|
||||||
"defaultMappingsSubmit": "保存默认映射",
|
"defaultMappingsSubmit": "保存默认映射",
|
||||||
"orgPoliciesEdit": "编辑组织策略",
|
"orgPoliciesEdit": "编辑组织策略",
|
||||||
"org": "组织",
|
"org": "组织",
|
||||||
@@ -1218,12 +1101,6 @@
|
|||||||
"actionGetUser": "获取用户",
|
"actionGetUser": "获取用户",
|
||||||
"actionGetOrgUser": "获取组织用户",
|
"actionGetOrgUser": "获取组织用户",
|
||||||
"actionListOrgDomains": "列出组织域",
|
"actionListOrgDomains": "列出组织域",
|
||||||
"actionGetDomain": "获取域",
|
|
||||||
"actionCreateOrgDomain": "创建域",
|
|
||||||
"actionUpdateOrgDomain": "更新域",
|
|
||||||
"actionDeleteOrgDomain": "删除域",
|
|
||||||
"actionGetDNSRecords": "获取 DNS 记录",
|
|
||||||
"actionRestartOrgDomain": "重新启动域",
|
|
||||||
"actionCreateSite": "创建站点",
|
"actionCreateSite": "创建站点",
|
||||||
"actionDeleteSite": "删除站点",
|
"actionDeleteSite": "删除站点",
|
||||||
"actionGetSite": "获取站点",
|
"actionGetSite": "获取站点",
|
||||||
@@ -1235,7 +1112,6 @@
|
|||||||
"setupTokenDescription": "从服务器控制台输入设置令牌。",
|
"setupTokenDescription": "从服务器控制台输入设置令牌。",
|
||||||
"setupTokenRequired": "需要设置令牌",
|
"setupTokenRequired": "需要设置令牌",
|
||||||
"actionUpdateSite": "更新站点",
|
"actionUpdateSite": "更新站点",
|
||||||
"actionResetSiteBandwidth": "重置组织带宽",
|
|
||||||
"actionListSiteRoles": "允许站点角色列表",
|
"actionListSiteRoles": "允许站点角色列表",
|
||||||
"actionCreateResource": "创建资源",
|
"actionCreateResource": "创建资源",
|
||||||
"actionDeleteResource": "删除资源",
|
"actionDeleteResource": "删除资源",
|
||||||
@@ -1265,7 +1141,6 @@
|
|||||||
"actionRemoveUser": "删除用户",
|
"actionRemoveUser": "删除用户",
|
||||||
"actionListUsers": "列出用户",
|
"actionListUsers": "列出用户",
|
||||||
"actionAddUserRole": "添加用户角色",
|
"actionAddUserRole": "添加用户角色",
|
||||||
"actionSetUserOrgRoles": "设置用户角色",
|
|
||||||
"actionGenerateAccessToken": "生成访问令牌",
|
"actionGenerateAccessToken": "生成访问令牌",
|
||||||
"actionDeleteAccessToken": "删除访问令牌",
|
"actionDeleteAccessToken": "删除访问令牌",
|
||||||
"actionListAccessTokens": "访问令牌",
|
"actionListAccessTokens": "访问令牌",
|
||||||
@@ -1310,7 +1185,6 @@
|
|||||||
"actionViewLogs": "查看日志",
|
"actionViewLogs": "查看日志",
|
||||||
"noneSelected": "未选择",
|
"noneSelected": "未选择",
|
||||||
"orgNotFound2": "未找到组织。",
|
"orgNotFound2": "未找到组织。",
|
||||||
"search": "搜索…",
|
|
||||||
"searchPlaceholder": "搜索...",
|
"searchPlaceholder": "搜索...",
|
||||||
"emptySearchOptions": "未找到选项",
|
"emptySearchOptions": "未找到选项",
|
||||||
"create": "创建",
|
"create": "创建",
|
||||||
@@ -1383,7 +1257,6 @@
|
|||||||
"sidebarRoles": "角色",
|
"sidebarRoles": "角色",
|
||||||
"sidebarShareableLinks": "链接",
|
"sidebarShareableLinks": "链接",
|
||||||
"sidebarApiKeys": "API密钥",
|
"sidebarApiKeys": "API密钥",
|
||||||
"sidebarProvisioning": "置备中",
|
|
||||||
"sidebarSettings": "设置",
|
"sidebarSettings": "设置",
|
||||||
"sidebarAllUsers": "所有用户",
|
"sidebarAllUsers": "所有用户",
|
||||||
"sidebarIdentityProviders": "身份提供商",
|
"sidebarIdentityProviders": "身份提供商",
|
||||||
@@ -1395,167 +1268,10 @@
|
|||||||
"sidebarGeneral": "管理",
|
"sidebarGeneral": "管理",
|
||||||
"sidebarLogAndAnalytics": "日志与分析",
|
"sidebarLogAndAnalytics": "日志与分析",
|
||||||
"sidebarBluePrints": "蓝图",
|
"sidebarBluePrints": "蓝图",
|
||||||
"sidebarAlerting": "告警",
|
|
||||||
"sidebarHealthChecks": "健康检查",
|
|
||||||
"sidebarOrganization": "组织",
|
"sidebarOrganization": "组织",
|
||||||
"sidebarManagement": "管理",
|
"sidebarManagement": "管理",
|
||||||
"sidebarBillingAndLicenses": "帐单和许可证",
|
"sidebarBillingAndLicenses": "帐单和许可证",
|
||||||
"sidebarLogsAnalytics": "分析",
|
"sidebarLogsAnalytics": "分析",
|
||||||
"alertingTitle": "告警",
|
|
||||||
"alertingDescription": "定义通知的来源、触发器和操作",
|
|
||||||
"alertingRules": "告警规则",
|
|
||||||
"alertingSearchRules": "搜索规则…",
|
|
||||||
"alertingAddRule": "创建规则",
|
|
||||||
"alertingColumnSource": "来源",
|
|
||||||
"alertingColumnTrigger": "触发",
|
|
||||||
"alertingColumnActions": "操作",
|
|
||||||
"alertingColumnEnabled": "已启用",
|
|
||||||
"alertingDeleteQuestion": "请确认您要删除此告警规则。",
|
|
||||||
"alertingDeleteRule": "删除告警规则",
|
|
||||||
"alertingRuleDeleted": "告警规则已删除",
|
|
||||||
"alertingRuleSaved": "告警规则已保存",
|
|
||||||
"alertingRuleSavedCreatedDescription": "您的新告警规则已创建。您可以在此页面继续编辑它。",
|
|
||||||
"alertingRuleSavedUpdatedDescription": "对此告警规则的更改已保存。",
|
|
||||||
"alertingEditRule": "编辑告警规则",
|
|
||||||
"alertingCreateRule": "创建告警规则",
|
|
||||||
"alertingRuleCredenzaDescription": "选择要监视的内容、何时触发以及如何通知",
|
|
||||||
"alertingRuleNamePlaceholder": "生产站点故障",
|
|
||||||
"alertingRuleEnabled": "规则已启用",
|
|
||||||
"alertingSectionSource": "来源",
|
|
||||||
"alertingSourceType": "来源类型",
|
|
||||||
"alertingSourceSite": "站点",
|
|
||||||
"alertingSourceHealthCheck": "健康检查",
|
|
||||||
"alertingPickSites": "站点",
|
|
||||||
"alertingPickHealthChecks": "健康检查",
|
|
||||||
"alertingPickResources": "资源",
|
|
||||||
"alertingAllSites": "所有站点",
|
|
||||||
"alertingAllSitesDescription": "任何站点的告警触发",
|
|
||||||
"alertingSpecificSites": "特定站点",
|
|
||||||
"alertingSpecificSitesDescription": "选择要监视的特定站点",
|
|
||||||
"alertingAllHealthChecks": "所有健康检查",
|
|
||||||
"alertingAllHealthChecksDescription": "任何健康检查的告警触发",
|
|
||||||
"alertingSpecificHealthChecks": "特定健康检查",
|
|
||||||
"alertingSpecificHealthChecksDescription": "选择要监视的特定健康检查",
|
|
||||||
"alertingAllResources": "所有资源",
|
|
||||||
"alertingAllResourcesDescription": "任何资源的告警触发",
|
|
||||||
"alertingSpecificResources": "特定资源",
|
|
||||||
"alertingSpecificResourcesDescription": "选择要监视的特定资源",
|
|
||||||
"alertingSelectResources": "选择资源…",
|
|
||||||
"alertingResourcesSelected": "{count} 个资源已选择",
|
|
||||||
"alertingResourcesEmpty": "在前 10 个结果中没有带目标的资源。",
|
|
||||||
"alertingSectionTrigger": "触发",
|
|
||||||
"alertingTrigger": "何时告警",
|
|
||||||
"alertingTriggerSiteOnline": "站点在线",
|
|
||||||
"alertingTriggerSiteOffline": "站点离线",
|
|
||||||
"alertingTriggerSiteToggle": "站点状态变更",
|
|
||||||
"alertingTriggerHcHealthy": "健康检查正常",
|
|
||||||
"alertingTriggerHcUnhealthy": "健康检查不正常",
|
|
||||||
"alertingTriggerHcToggle": "健康检查状态变更",
|
|
||||||
"alertingTriggerResourceHealthy": "资源正常",
|
|
||||||
"alertingTriggerResourceUnhealthy": "资源不正常",
|
|
||||||
"alertingTriggerResourceDegraded": "资源降级",
|
|
||||||
"alertingSearchHealthChecks": "搜索健康检查…",
|
|
||||||
"alertingHealthChecksEmpty": "无可用健康检查。",
|
|
||||||
"alertingTriggerResourceToggle": "资源状态变更",
|
|
||||||
"alertingSourceResource": "资源",
|
|
||||||
"alertingSectionActions": "操作",
|
|
||||||
"alertingAddAction": "新增操作",
|
|
||||||
"alertingActionNotify": "电子邮件",
|
|
||||||
"alertingActionNotifyDescription": "向用户或角色发送电子邮件通知",
|
|
||||||
"alertingActionWebhook": "Webhook",
|
|
||||||
"alertingActionWebhookDescription": "发送 HTTP 请求到自定义终端",
|
|
||||||
"alertingExternalIntegration": "外部集成",
|
|
||||||
"alertingExternalPagerDutyDescription": "将告警发送给 PagerDuty 以进行事件管理",
|
|
||||||
"alertingExternalOpsgenieDescription": "将告警路由到 Opsgenie 进行电话值班管理",
|
|
||||||
"alertingExternalServiceNowDescription": "从告警事件创建 ServiceNow 事件",
|
|
||||||
"alertingExternalIncidentIoDescription": "从告警事件触发 Incident.io 工作流程",
|
|
||||||
"alertingActionType": "操作类型",
|
|
||||||
"alertingNotifyUsers": "用户",
|
|
||||||
"alertingNotifyRoles": "角色",
|
|
||||||
"alertingNotifyEmails": "电子邮件地址",
|
|
||||||
"alertingEmailPlaceholder": "添加电子邮件并按回车键",
|
|
||||||
"alertingWebhookMethod": "HTTP 方法",
|
|
||||||
"alertingWebhookSecret": "签名密钥(可选)",
|
|
||||||
"alertingWebhookSecretPlaceholder": "HMAC 密钥",
|
|
||||||
"alertingWebhookHeaders": "标头",
|
|
||||||
"alertingAddHeader": "添加标头",
|
|
||||||
"alertingSelectSites": "选择站点…",
|
|
||||||
"alertingSitesSelected": "{count} 个站点已选择",
|
|
||||||
"alertingSelectHealthChecks": "选择健康检查…",
|
|
||||||
"alertingHealthChecksSelected": "{count} 个健康检查已选择",
|
|
||||||
"alertingNoHealthChecks": "没有启用健康检查的目标",
|
|
||||||
"alertingHealthCheckStub": "健康检查来源选择尚未配置 - 你仍然可以配置触发器和操作。",
|
|
||||||
"alertingSelectUsers": "选择用户…",
|
|
||||||
"alertingUsersSelected": "{count} 个用户已选择",
|
|
||||||
"alertingSelectRoles": "选择角色…",
|
|
||||||
"alertingRolesSelected": "{count} 个角色已选择",
|
|
||||||
"alertingSummarySites": "站点 ({count})",
|
|
||||||
"alertingSummaryAllSites": "所有站点",
|
|
||||||
"alertingSummaryHealthChecks": "健康检查 ({count})",
|
|
||||||
"alertingSummaryAllHealthChecks": "所有健康检查",
|
|
||||||
"alertingSummaryResources": "资源 ({count})",
|
|
||||||
"alertingSummaryAllResources": "所有资源",
|
|
||||||
"alertingErrorNameRequired": "输入名称",
|
|
||||||
"alertingErrorActionsMin": "添加至少一个操作",
|
|
||||||
"alertingErrorPickSites": "至少选择一个站点",
|
|
||||||
"alertingErrorPickHealthChecks": "至少选择一个健康检查",
|
|
||||||
"alertingErrorPickResources": "至少选择一个资源",
|
|
||||||
"alertingErrorTriggerSite": "选择站点触发器",
|
|
||||||
"alertingErrorTriggerHealth": "选择健康检查触发器",
|
|
||||||
"alertingErrorTriggerResource": "选择资源触发器",
|
|
||||||
"alertingErrorNotifyRecipients": "选择用户、角色或至少一个电子邮件",
|
|
||||||
"alertingConfigureSource": "配置来源",
|
|
||||||
"alertingConfigureTrigger": "配置触发器",
|
|
||||||
"alertingConfigureActions": "配置操作",
|
|
||||||
"alertingBackToRules": "返回规则",
|
|
||||||
"alertingRuleCooldown": "冷却时间(秒)",
|
|
||||||
"alertingRuleCooldownDescription": "相同规则间隔重复告警的最小时间。设置为 0 固定触发。",
|
|
||||||
"alertingDraftBadge": "草稿 - 保存以存储此规则",
|
|
||||||
"alertingSidebarHint": "点击画布上的步骤在此处编辑。",
|
|
||||||
"alertingGraphCanvasTitle": "规则流程",
|
|
||||||
"alertingGraphCanvasDescription": "源、触发器和操作的视觉概况。选择一个节点,在面板上进行编辑。",
|
|
||||||
"alertingNodeNotConfigured": "尚未配置",
|
|
||||||
"alertingNodeActionsCount": "{count, plural, other {# 操作}}",
|
|
||||||
"alertingNodeRoleSource": "来源",
|
|
||||||
"alertingNodeRoleTrigger": "触发",
|
|
||||||
"alertingNodeRoleAction": "行为",
|
|
||||||
"alertingTabRules": "告警规则",
|
|
||||||
"alertingTabHealthChecks": "健康检查",
|
|
||||||
"alertingRulesBannerTitle": "获取通知",
|
|
||||||
"alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。",
|
|
||||||
"alertingHealthChecksBannerTitle": "监视健康和资源",
|
|
||||||
"alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。",
|
|
||||||
"standaloneHcTableTitle": "健康检查",
|
|
||||||
"standaloneHcSearchPlaceholder": "搜索健康检查…",
|
|
||||||
"standaloneHcAddButton": "创建健康检查",
|
|
||||||
"standaloneHcCreateTitle": "创建健康检查",
|
|
||||||
"standaloneHcEditTitle": "编辑健康检查",
|
|
||||||
"standaloneHcDescription": "配置 HTTP 或 TCP 健康检查以用于告警规则。",
|
|
||||||
"standaloneHcNameLabel": "名称",
|
|
||||||
"standaloneHcNamePlaceholder": "我的 HTTP 监视器",
|
|
||||||
"standaloneHcDeleteTitle": "删除健康检查",
|
|
||||||
"standaloneHcDeleteQuestion": "请确认您要删除此健康检查。",
|
|
||||||
"standaloneHcDeleted": "健康检查已删除",
|
|
||||||
"standaloneHcSaved": "健康检查已保存",
|
|
||||||
"standaloneHcColumnHealth": "健康",
|
|
||||||
"standaloneHcColumnMode": "模式",
|
|
||||||
"standaloneHcColumnTarget": "目标",
|
|
||||||
"standaloneHcHealthStateHealthy": "健康",
|
|
||||||
"standaloneHcHealthStateUnhealthy": "不健康",
|
|
||||||
"standaloneHcHealthStateUnknown": "未知",
|
|
||||||
"standaloneHcFilterAnySite": "所有站点",
|
|
||||||
"standaloneHcFilterAnyResource": "所有资源",
|
|
||||||
"standaloneHcFilterMode": "模式",
|
|
||||||
"standaloneHcFilterModeHttp": "HTTP",
|
|
||||||
"standaloneHcFilterModeTcp": "TCP",
|
|
||||||
"standaloneHcFilterModeSnmp": "SNMP",
|
|
||||||
"standaloneHcFilterModePing": "Ping",
|
|
||||||
"standaloneHcFilterHealth": "健康",
|
|
||||||
"standaloneHcFilterEnabled": "已启用",
|
|
||||||
"standaloneHcFilterEnabledOn": "已启用",
|
|
||||||
"standaloneHcFilterEnabledOff": "已禁用",
|
|
||||||
"standaloneHcFilterSiteIdFallback": "站点 {id}",
|
|
||||||
"standaloneHcFilterResourceIdFallback": "资源 {id}",
|
|
||||||
"blueprints": "蓝图",
|
"blueprints": "蓝图",
|
||||||
"blueprintsDescription": "应用声明配置并查看先前运行的",
|
"blueprintsDescription": "应用声明配置并查看先前运行的",
|
||||||
"blueprintAdd": "添加蓝图",
|
"blueprintAdd": "添加蓝图",
|
||||||
@@ -1617,8 +1333,7 @@
|
|||||||
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
||||||
"createAdminAccount": "创建管理员帐户",
|
"createAdminAccount": "创建管理员帐户",
|
||||||
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
||||||
"certificateStatus": "证书",
|
"certificateStatus": "证书状态",
|
||||||
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
|
||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
"loadingAnalytics": "加载分析",
|
"loadingAnalytics": "加载分析",
|
||||||
"restart": "重启",
|
"restart": "重启",
|
||||||
@@ -1687,7 +1402,6 @@
|
|||||||
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
|
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
|
||||||
"newtUpdateAvailable": "更新可用",
|
"newtUpdateAvailable": "更新可用",
|
||||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
|
||||||
"pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
|
|
||||||
"domainPickerEnterDomain": "域名",
|
"domainPickerEnterDomain": "域名",
|
||||||
"domainPickerPlaceholder": "example.com",
|
"domainPickerPlaceholder": "example.com",
|
||||||
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
|
||||||
@@ -1705,7 +1419,6 @@
|
|||||||
"domainPickerNamespace": "命名空间:{namespace}",
|
"domainPickerNamespace": "命名空间:{namespace}",
|
||||||
"domainPickerShowMore": "显示更多",
|
"domainPickerShowMore": "显示更多",
|
||||||
"regionSelectorTitle": "选择区域",
|
"regionSelectorTitle": "选择区域",
|
||||||
"domainPickerRemoteExitNodeWarning": "当站点连接到远程退出节点时不支持所提供的域。为了资源可在远程节点上使用,请使用自定义域名。",
|
|
||||||
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
|
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
|
||||||
"regionSelectorPlaceholder": "选择一个区域",
|
"regionSelectorPlaceholder": "选择一个区域",
|
||||||
"regionSelectorComingSoon": "即将推出",
|
"regionSelectorComingSoon": "即将推出",
|
||||||
@@ -1927,7 +1640,6 @@
|
|||||||
"configureHealthCheck": "配置健康检查",
|
"configureHealthCheck": "配置健康检查",
|
||||||
"configureHealthCheckDescription": "为 {target} 设置健康监控",
|
"configureHealthCheckDescription": "为 {target} 设置健康监控",
|
||||||
"enableHealthChecks": "启用健康检查",
|
"enableHealthChecks": "启用健康检查",
|
||||||
"healthCheckDisabledStateDescription": "禁用后,站点不会进行健康检查,状态将被视为未知。",
|
|
||||||
"enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。",
|
"enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。",
|
||||||
"healthScheme": "方法",
|
"healthScheme": "方法",
|
||||||
"healthSelectScheme": "选择方法",
|
"healthSelectScheme": "选择方法",
|
||||||
@@ -1957,10 +1669,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
|
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
|
||||||
"sshSudo": "允许Sudo",
|
"sshSudo": "允许Sudo",
|
||||||
"sshSudoCommands": "Sudo 命令",
|
"sshSudoCommands": "Sudo 命令",
|
||||||
"sshSudoCommandsDescription": "逗号分隔的用户允许使用 sudo 运行的命令列表。",
|
"sshSudoCommandsDescription": "允许用户使用 sudo 运行的命令列表。",
|
||||||
"sshCreateHomeDir": "创建主目录",
|
"sshCreateHomeDir": "创建主目录",
|
||||||
"sshUnixGroups": "Unix 组",
|
"sshUnixGroups": "Unix 组",
|
||||||
"sshUnixGroupsDescription": "用逗号分隔了Unix组,将用户添加到目标主机上。",
|
"sshUnixGroupsDescription": "将用户添加到目标主机的Unix组。",
|
||||||
"retryAttempts": "重试次数",
|
"retryAttempts": "重试次数",
|
||||||
"expectedResponseCodes": "期望响应代码",
|
"expectedResponseCodes": "期望响应代码",
|
||||||
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
|
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
|
||||||
@@ -1977,20 +1689,9 @@
|
|||||||
"healthCheckIntervalMin": "检查间隔必须至少为 5 秒",
|
"healthCheckIntervalMin": "检查间隔必须至少为 5 秒",
|
||||||
"healthCheckTimeoutMin": "超时必须至少为 1 秒",
|
"healthCheckTimeoutMin": "超时必须至少为 1 秒",
|
||||||
"healthCheckRetryMin": "重试次数必须至少为 1 次",
|
"healthCheckRetryMin": "重试次数必须至少为 1 次",
|
||||||
"healthCheckMode": "检查模式",
|
|
||||||
"healthCheckStrategy": "策略",
|
|
||||||
"healthCheckModeDescription": "TCP 模式仅验证连接性。HTTP 模式验证 HTTP 响应。",
|
|
||||||
"healthyThreshold": "正常阈值",
|
|
||||||
"healthyThresholdDescription": "标记为正常之前所需的连续成功次数。",
|
|
||||||
"unhealthyThreshold": "不正常阈值",
|
|
||||||
"unhealthyThresholdDescription": "标记为不正常之前所需的连续失败次数。",
|
|
||||||
"healthCheckHealthyThresholdMin": "健康阈值至少为 1",
|
|
||||||
"healthCheckUnhealthyThresholdMin": "不健康阈值至少为 1",
|
|
||||||
"httpMethod": "HTTP 方法",
|
"httpMethod": "HTTP 方法",
|
||||||
"selectHttpMethod": "选择 HTTP 方法",
|
"selectHttpMethod": "选择 HTTP 方法",
|
||||||
"domainPickerSubdomainLabel": "子域名",
|
"domainPickerSubdomainLabel": "子域名",
|
||||||
"domainPickerWildcard": "通配符",
|
|
||||||
"domainPickerWildcardPaidOnly": "通配符子域是付费功能。请升级以使用此功能。",
|
|
||||||
"domainPickerBaseDomainLabel": "根域名",
|
"domainPickerBaseDomainLabel": "根域名",
|
||||||
"domainPickerSearchDomains": "搜索域名...",
|
"domainPickerSearchDomains": "搜索域名...",
|
||||||
"domainPickerNoDomainsFound": "未找到域名",
|
"domainPickerNoDomainsFound": "未找到域名",
|
||||||
@@ -2016,12 +1717,12 @@
|
|||||||
"resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。",
|
"resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。",
|
||||||
"resourcesTableClients": "客户端",
|
"resourcesTableClients": "客户端",
|
||||||
"resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。",
|
"resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。",
|
||||||
|
"resourcesTableNoTargets": "没有目标",
|
||||||
"resourcesTableHealthy": "健康的",
|
"resourcesTableHealthy": "健康的",
|
||||||
"resourcesTableDegraded": "降级",
|
"resourcesTableDegraded": "降级",
|
||||||
"resourcesTableUnhealthy": "不健康",
|
"resourcesTableOffline": "离线的",
|
||||||
"resourcesTableUnknown": "未知的",
|
"resourcesTableUnknown": "未知的",
|
||||||
"resourcesTableNotMonitored": "未监视的",
|
"resourcesTableNotMonitored": "未监视的",
|
||||||
"resourcesTableNoTargets": "无目标",
|
|
||||||
"editInternalResourceDialogEditClientResource": "编辑私有资源",
|
"editInternalResourceDialogEditClientResource": "编辑私有资源",
|
||||||
"editInternalResourceDialogUpdateResourceProperties": "更新{resourceName}的资源配置和访问控制。",
|
"editInternalResourceDialogUpdateResourceProperties": "更新{resourceName}的资源配置和访问控制。",
|
||||||
"editInternalResourceDialogResourceProperties": "资源属性",
|
"editInternalResourceDialogResourceProperties": "资源属性",
|
||||||
@@ -2047,11 +1748,6 @@
|
|||||||
"editInternalResourceDialogModePort": "端口",
|
"editInternalResourceDialogModePort": "端口",
|
||||||
"editInternalResourceDialogModeHost": "主机",
|
"editInternalResourceDialogModeHost": "主机",
|
||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
|
||||||
"editInternalResourceDialogScheme": "方案",
|
|
||||||
"editInternalResourceDialogEnableSsl": "启用 TLS",
|
|
||||||
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
|
||||||
"editInternalResourceDialogDestination": "目标",
|
"editInternalResourceDialogDestination": "目标",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
"editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
||||||
"editInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。",
|
"editInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。",
|
||||||
@@ -2067,7 +1763,6 @@
|
|||||||
"createInternalResourceDialogName": "名称",
|
"createInternalResourceDialogName": "名称",
|
||||||
"createInternalResourceDialogSite": "站点",
|
"createInternalResourceDialogSite": "站点",
|
||||||
"selectSite": "选择站点...",
|
"selectSite": "选择站点...",
|
||||||
"multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}",
|
|
||||||
"noSitesFound": "未找到站点。",
|
"noSitesFound": "未找到站点。",
|
||||||
"createInternalResourceDialogProtocol": "协议",
|
"createInternalResourceDialogProtocol": "协议",
|
||||||
"createInternalResourceDialogTcp": "TCP",
|
"createInternalResourceDialogTcp": "TCP",
|
||||||
@@ -2096,19 +1791,11 @@
|
|||||||
"createInternalResourceDialogModePort": "端口",
|
"createInternalResourceDialogModePort": "端口",
|
||||||
"createInternalResourceDialogModeHost": "主机",
|
"createInternalResourceDialogModeHost": "主机",
|
||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
|
||||||
"scheme": "方案",
|
|
||||||
"createInternalResourceDialogScheme": "方案",
|
|
||||||
"createInternalResourceDialogEnableSsl": "启用 TLS",
|
|
||||||
"createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
|
||||||
"createInternalResourceDialogDestination": "目标",
|
"createInternalResourceDialogDestination": "目标",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
"createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
|
||||||
"createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。",
|
"createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。",
|
||||||
"createInternalResourceDialogAlias": "Alias",
|
"createInternalResourceDialogAlias": "Alias",
|
||||||
"createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。",
|
"createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。",
|
||||||
"internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案",
|
|
||||||
"internalResourceHttpPortRequired": "HTTP 资源需要目的端口",
|
|
||||||
"siteConfiguration": "配置",
|
"siteConfiguration": "配置",
|
||||||
"siteAcceptClientConnections": "接受客户端连接",
|
"siteAcceptClientConnections": "接受客户端连接",
|
||||||
"siteAcceptClientConnectionsDescription": "允许用户设备和客户端访问此站点上的资源。这可以稍后更改。",
|
"siteAcceptClientConnectionsDescription": "允许用户设备和客户端访问此站点上的资源。这可以稍后更改。",
|
||||||
@@ -2194,46 +1881,12 @@
|
|||||||
"exitNode": "出口节点",
|
"exitNode": "出口节点",
|
||||||
"country": "国家",
|
"country": "国家",
|
||||||
"rulesMatchCountry": "当前基于源 IP",
|
"rulesMatchCountry": "当前基于源 IP",
|
||||||
"region": "地区",
|
|
||||||
"selectRegion": "选择区域",
|
|
||||||
"searchRegions": "搜索区域...",
|
|
||||||
"noRegionFound": "未找到区域。",
|
|
||||||
"rulesMatchRegion": "选择一个区域国家组",
|
|
||||||
"rulesErrorInvalidRegion": "无效区域",
|
|
||||||
"rulesErrorInvalidRegionDescription": "请选择一个有效的区域。",
|
|
||||||
"regionAfrica": "非洲",
|
|
||||||
"regionNorthernAfrica": "B. 北非地区",
|
|
||||||
"regionEasternAfrica": "东部非洲",
|
|
||||||
"regionMiddleAfrica": "中东",
|
|
||||||
"regionSouthernAfrica": "D. 南 非",
|
|
||||||
"regionWesternAfrica": "D. 西部非洲",
|
|
||||||
"regionAmericas": "Americas",
|
|
||||||
"regionCaribbean": "加勒比",
|
|
||||||
"regionCentralAmerica": "中美洲:",
|
|
||||||
"regionSouthAmerica": "南 非",
|
|
||||||
"regionNorthernAmerica": "北美洲:",
|
|
||||||
"regionAsia": "亚洲",
|
|
||||||
"regionCentralAsia": "B. 亚 洲",
|
|
||||||
"regionEasternAsia": "东亚",
|
|
||||||
"regionSouthEasternAsia": "D. 东南亚区域",
|
|
||||||
"regionSouthernAsia": "D. 亚 洲",
|
|
||||||
"regionWesternAsia": "西亚",
|
|
||||||
"regionEurope": "欧洲",
|
|
||||||
"regionEasternEurope": "D. 欧 洲",
|
|
||||||
"regionNorthernEurope": "北欧洲",
|
|
||||||
"regionSouthernEurope": "南欧洲",
|
|
||||||
"regionWesternEurope": "西欧洲",
|
|
||||||
"regionOceania": "Oceania",
|
|
||||||
"regionAustraliaAndNewZealand": "澳大利亚和新西兰",
|
|
||||||
"regionMelanesia": "Melanesia",
|
|
||||||
"regionMicronesia": "Micronesia",
|
|
||||||
"regionPolynesia": "Polynesia",
|
|
||||||
"managedSelfHosted": {
|
"managedSelfHosted": {
|
||||||
"title": "托管自托管",
|
"title": "托管自托管",
|
||||||
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
|
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
|
||||||
"introTitle": "托管自托管的潘戈林公司",
|
"introTitle": "托管自托管的潘戈林公司",
|
||||||
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
|
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
|
||||||
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、TLS 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
|
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "简单的操作",
|
"title": "简单的操作",
|
||||||
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
|
||||||
@@ -2266,7 +1919,7 @@
|
|||||||
},
|
},
|
||||||
"internationaldomaindetected": "检测到国际域",
|
"internationaldomaindetected": "检测到国际域",
|
||||||
"willbestoredas": "储存为:",
|
"willbestoredas": "储存为:",
|
||||||
"roleMappingDescription": "确定当用户使用此身份提供者登陆时如何分配角色。",
|
"roleMappingDescription": "确定当用户启用自动配送时如何分配他们的角色。",
|
||||||
"selectRole": "选择角色",
|
"selectRole": "选择角色",
|
||||||
"roleMappingExpression": "表达式",
|
"roleMappingExpression": "表达式",
|
||||||
"selectRolePlaceholder": "选择角色",
|
"selectRolePlaceholder": "选择角色",
|
||||||
@@ -2276,25 +1929,6 @@
|
|||||||
"invalidValue": "无效的值",
|
"invalidValue": "无效的值",
|
||||||
"idpTypeLabel": "身份提供者类型",
|
"idpTypeLabel": "身份提供者类型",
|
||||||
"roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'",
|
"roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'",
|
||||||
"roleMappingModeFixedRoles": "固定角色",
|
|
||||||
"roleMappingModeMappingBuilder": "映射构建器",
|
|
||||||
"roleMappingModeRawExpression": "原始表达式",
|
|
||||||
"roleMappingFixedRolesPlaceholderSelect": "选择一个或多个角色",
|
|
||||||
"roleMappingFixedRolesPlaceholderFreeform": "输入角色名称 (每个组织确切匹配)",
|
|
||||||
"roleMappingFixedRolesDescriptionSameForAll": "将相同的角色分配给每个自动配备的用户。",
|
|
||||||
"roleMappingFixedRolesDescriptionDefaultPolicy": "对于缺省策略,每个提供用户的组织中存在的角色名称类型。名称必须完全匹配。",
|
|
||||||
"roleMappingClaimPath": "认领路径",
|
|
||||||
"roleMappingClaimPathPlaceholder": "组",
|
|
||||||
"roleMappingClaimPathDescription": "包含源值的 token 有效负载路径 (例如组)。",
|
|
||||||
"roleMappingMatchValue": "匹配值",
|
|
||||||
"roleMappingAssignRoles": "分配角色",
|
|
||||||
"roleMappingAddMappingRule": "添加映射规则",
|
|
||||||
"roleMappingRawExpressionResultDescription": "表达式必须值为字符串或字符串。",
|
|
||||||
"roleMappingRawExpressionResultDescriptionSingleRole": "表达式必须计算到字符串(单个角色名称)。",
|
|
||||||
"roleMappingMatchValuePlaceholder": "匹配值(例如: 管理员)",
|
|
||||||
"roleMappingAssignRolesPlaceholderFreeform": "输入角色名称 (每个组织确切)",
|
|
||||||
"roleMappingBuilderFreeformRowHint": "角色名称必须匹配每个目标组织的角色。",
|
|
||||||
"roleMappingRemoveRule": "删除",
|
|
||||||
"idpGoogleConfiguration": "Google 配置",
|
"idpGoogleConfiguration": "Google 配置",
|
||||||
"idpGoogleConfigurationDescription": "配置 Google OAuth2 凭据",
|
"idpGoogleConfigurationDescription": "配置 Google OAuth2 凭据",
|
||||||
"idpGoogleClientIdDescription": "Google OAuth2 Client ID",
|
"idpGoogleClientIdDescription": "Google OAuth2 Client ID",
|
||||||
@@ -2358,11 +1992,9 @@
|
|||||||
"selectDomainForOrgAuthPage": "选择组织认证页面的域",
|
"selectDomainForOrgAuthPage": "选择组织认证页面的域",
|
||||||
"domainPickerProvidedDomain": "提供的域",
|
"domainPickerProvidedDomain": "提供的域",
|
||||||
"domainPickerFreeProvidedDomain": "免费提供的域",
|
"domainPickerFreeProvidedDomain": "免费提供的域",
|
||||||
"domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中-无需自带域名。",
|
|
||||||
"domainPickerVerified": "已验证",
|
"domainPickerVerified": "已验证",
|
||||||
"domainPickerUnverified": "未验证",
|
"domainPickerUnverified": "未验证",
|
||||||
"domainPickerManual": "手动",
|
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
|
||||||
"domainPickerInvalidSubdomainStructure": "保存时将清除无效字符。",
|
|
||||||
"domainPickerError": "错误",
|
"domainPickerError": "错误",
|
||||||
"domainPickerErrorLoadDomains": "加载组织域名失败",
|
"domainPickerErrorLoadDomains": "加载组织域名失败",
|
||||||
"domainPickerErrorCheckAvailability": "检查域可用性失败",
|
"domainPickerErrorCheckAvailability": "检查域可用性失败",
|
||||||
@@ -2375,7 +2007,7 @@
|
|||||||
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
|
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
|
||||||
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
|
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
|
||||||
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
|
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
|
||||||
"orgAuthSignInToOrg": "组织身份提供商 (SSO)",
|
"orgAuthSignInToOrg": "登录到组织",
|
||||||
"orgAuthSelectOrgTitle": "组织登录",
|
"orgAuthSelectOrgTitle": "组织登录",
|
||||||
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
|
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
|
||||||
"orgAuthOrgIdPlaceholder": "您的组织",
|
"orgAuthOrgIdPlaceholder": "您的组织",
|
||||||
@@ -2536,7 +2168,7 @@
|
|||||||
"alerts": {
|
"alerts": {
|
||||||
"commercialUseDisclosure": {
|
"commercialUseDisclosure": {
|
||||||
"title": "使用情况披露",
|
"title": "使用情况披露",
|
||||||
"description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境--需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。"
|
"description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境——需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。"
|
||||||
},
|
},
|
||||||
"trialPeriodInformation": {
|
"trialPeriodInformation": {
|
||||||
"title": "试用期信息",
|
"title": "试用期信息",
|
||||||
@@ -2591,10 +2223,10 @@
|
|||||||
},
|
},
|
||||||
"scale": {
|
"scale": {
|
||||||
"title": "缩放比例",
|
"title": "缩放比例",
|
||||||
"description": "企业功能,50个用户,100个站点,以及优先支持。"
|
"description": "企业特征、50个用户、50个站点和优先支持。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "仅限个人使用(免费许可 - 无需结账)",
|
"personalUseOnly": "仅供个人使用 (免费许可证-无签出)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "继续签出"
|
"continueToCheckout": "继续签出"
|
||||||
},
|
},
|
||||||
@@ -2668,9 +2300,6 @@
|
|||||||
"validPassword": "有效密码",
|
"validPassword": "有效密码",
|
||||||
"validEmail": "Valid email",
|
"validEmail": "Valid email",
|
||||||
"validSSO": "Valid SSO",
|
"validSSO": "Valid SSO",
|
||||||
"view": "查看",
|
|
||||||
"configManaged": "配置已管理",
|
|
||||||
"connectedClient": "已连接客户端",
|
|
||||||
"resourceBlocked": "资源被阻止",
|
"resourceBlocked": "资源被阻止",
|
||||||
"droppedByRule": "被规则删除",
|
"droppedByRule": "被规则删除",
|
||||||
"noSessions": "无会话",
|
"noSessions": "无会话",
|
||||||
@@ -2690,14 +2319,12 @@
|
|||||||
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
|
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
|
||||||
"requestLogsDescription": "查看此机构资源的详细请求日志",
|
"requestLogsDescription": "查看此机构资源的详细请求日志",
|
||||||
"requestAnalyticsDescription": "查看此机构资源的详细请求分析",
|
"requestAnalyticsDescription": "查看此机构资源的详细请求分析",
|
||||||
"logRetentionRequestLabel": "HTTP 请求日志保留",
|
"logRetentionRequestLabel": "请求日志保留",
|
||||||
"logRetentionRequestDescription": "保留请求日志的时间",
|
"logRetentionRequestDescription": "保留请求日志的时间",
|
||||||
"logRetentionAccessLabel": "访问日志保留",
|
"logRetentionAccessLabel": "访问日志保留",
|
||||||
"logRetentionAccessDescription": "保留访问日志的时间",
|
"logRetentionAccessDescription": "保留访问日志的时间",
|
||||||
"logRetentionActionLabel": "动作日志保留",
|
"logRetentionActionLabel": "动作日志保留",
|
||||||
"logRetentionActionDescription": "保留操作日志的时间",
|
"logRetentionActionDescription": "保留操作日志的时间",
|
||||||
"logRetentionConnectionLabel": "连接日志保留",
|
|
||||||
"logRetentionConnectionDescription": "保留连接日志的时间",
|
|
||||||
"logRetentionDisabled": "已禁用",
|
"logRetentionDisabled": "已禁用",
|
||||||
"logRetention3Days": "3 天",
|
"logRetention3Days": "3 天",
|
||||||
"logRetention7Days": "7 天",
|
"logRetention7Days": "7 天",
|
||||||
@@ -2708,15 +2335,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "下一年结束",
|
"logRetentionEndOfFollowingYear": "下一年结束",
|
||||||
"actionLogsDescription": "查看此机构执行的操作历史",
|
"actionLogsDescription": "查看此机构执行的操作历史",
|
||||||
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
||||||
"connectionLogs": "连接日志",
|
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
||||||
"connectionLogsDescription": "查看此机构隧道的连接日志",
|
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
||||||
"sidebarLogsConnection": "连接日志",
|
|
||||||
"sidebarLogsStreaming": "流流",
|
|
||||||
"sourceAddress": "源地址",
|
|
||||||
"destinationAddress": "目的地址",
|
|
||||||
"duration": "期限",
|
|
||||||
"licenseRequiredToUse": "使用此功能需要<enterpriseLicenseLink>企业版</enterpriseLicenseLink>许可证或<pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>。<bookADemoLink>预约演示或POC试用</bookADemoLink>。",
|
|
||||||
"ossEnterpriseEditionRequired": "需要 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 才能使用此功能。 此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>上获取。 <bookADemoLink>预订演示或POC 试用</bookADemoLink>。",
|
|
||||||
"certResolver": "证书解决器",
|
"certResolver": "证书解决器",
|
||||||
"certResolverDescription": "选择用于此资源的证书解析器。",
|
"certResolverDescription": "选择用于此资源的证书解析器。",
|
||||||
"selectCertResolver": "选择证书解析",
|
"selectCertResolver": "选择证书解析",
|
||||||
@@ -2858,9 +2478,6 @@
|
|||||||
"machineClients": "机器客户端",
|
"machineClients": "机器客户端",
|
||||||
"install": "安装",
|
"install": "安装",
|
||||||
"run": "运行",
|
"run": "运行",
|
||||||
"envFile": "环境文件",
|
|
||||||
"serviceFile": "服务文件",
|
|
||||||
"enableAndStart": "启用并启动",
|
|
||||||
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
|
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
|
||||||
"clientAddress": "客户端地址 (高级)",
|
"clientAddress": "客户端地址 (高级)",
|
||||||
"setupFailedToFetchSubnet": "获取默认子网失败",
|
"setupFailedToFetchSubnet": "获取默认子网失败",
|
||||||
@@ -2909,13 +2526,7 @@
|
|||||||
"editInternalResourceDialogAddClients": "添加客户端",
|
"editInternalResourceDialogAddClients": "添加客户端",
|
||||||
"editInternalResourceDialogDestinationLabel": "目标",
|
"editInternalResourceDialogDestinationLabel": "目标",
|
||||||
"editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。",
|
"editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。",
|
||||||
"internalResourceFormMultiSiteRoutingHelp": "选择多个站点可以实现高可用性的弹性路由和故障转移。",
|
|
||||||
"internalResourceFormMultiSiteRoutingHelpLearnMore": "了解更多",
|
|
||||||
"editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。",
|
"editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。",
|
||||||
"createInternalResourceDialogHttpConfiguration": "HTTP 配置",
|
|
||||||
"createInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。",
|
|
||||||
"editInternalResourceDialogHttpConfiguration": "HTTP 配置",
|
|
||||||
"editInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。",
|
|
||||||
"editInternalResourceDialogTcp": "TCP",
|
"editInternalResourceDialogTcp": "TCP",
|
||||||
"editInternalResourceDialogUdp": "UDP",
|
"editInternalResourceDialogUdp": "UDP",
|
||||||
"editInternalResourceDialogIcmp": "ICMP",
|
"editInternalResourceDialogIcmp": "ICMP",
|
||||||
@@ -2954,9 +2565,6 @@
|
|||||||
"maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。",
|
"maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。",
|
||||||
"maintenancePageMessageDescription": "详细说明维护的消息",
|
"maintenancePageMessageDescription": "详细说明维护的消息",
|
||||||
"maintenancePageTimeTitle": "预计完成时间(可选)",
|
"maintenancePageTimeTitle": "预计完成时间(可选)",
|
||||||
"privateMaintenanceScreenTitle": "私有占位符界面",
|
|
||||||
"privateMaintenanceScreenMessage": "此域名正在私有资源上使用。请连接 Pangolin 客户端以访问此资源。",
|
|
||||||
"privateMaintenanceScreenSteps": "连接后,如果您仍然看到此消息,说明您的浏览器的DNS缓存可能仍指向旧地址。解决方法:完全关闭并重新打开此标签页或浏览器,然后返回此页面。",
|
|
||||||
"maintenanceTime": "例如,2小时,11月1日下午5:00",
|
"maintenanceTime": "例如,2小时,11月1日下午5:00",
|
||||||
"maintenanceEstimatedTimeDescription": "您期望维护完成的时间",
|
"maintenanceEstimatedTimeDescription": "您期望维护完成的时间",
|
||||||
"editDomain": "编辑域名",
|
"editDomain": "编辑域名",
|
||||||
@@ -3065,236 +2673,5 @@
|
|||||||
"approvalsEmptyStateStep2Title": "启用设备批准",
|
"approvalsEmptyStateStep2Title": "启用设备批准",
|
||||||
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
|
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
|
||||||
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
|
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
|
||||||
"approvalsEmptyStateButtonText": "管理角色",
|
"approvalsEmptyStateButtonText": "管理角色"
|
||||||
"domainErrorTitle": "我们在验证您的域名时遇到了问题",
|
|
||||||
"idpAdminAutoProvisionPoliciesTabHint": "在 <policiesTabLink>自动供应设置</policiesTabLink> 选项卡上配置角色映射和组织策略。",
|
|
||||||
"streamingTitle": "事件流",
|
|
||||||
"streamingDescription": "实时将事件从您的组织流到外部目的地。",
|
|
||||||
"streamingUnnamedDestination": "未命名目标",
|
|
||||||
"streamingNoUrlConfigured": "未配置URL",
|
|
||||||
"streamingAddDestination": "添加目标",
|
|
||||||
"streamingHttpWebhookTitle": "HTTP Webhook",
|
|
||||||
"streamingHttpWebhookDescription": "将事件发送到任意HTTP端点并灵活验证和模板。",
|
|
||||||
"streamingS3Title": "Amazon S3",
|
|
||||||
"streamingS3Description": "将事件串流到 S3 兼容的对象存储桶。即将推出。",
|
|
||||||
"streamingDatadogTitle": "Datadog",
|
|
||||||
"streamingDatadogDescription": "直接转发事件到您的Datadog 帐户。即将推出。",
|
|
||||||
"streamingTypePickerDescription": "选择要开始的目标类型。",
|
|
||||||
"streamingLastSyncError": "最后一次同步时发生错误",
|
|
||||||
"streamingUnexpectedError": "发生意外错误.",
|
|
||||||
"streamingFailedToUpdate": "更新目标失败",
|
|
||||||
"streamingDeletedSuccess": "目标删除成功",
|
|
||||||
"streamingFailedToDelete": "删除目标失败",
|
|
||||||
"streamingDeleteTitle": "删除目标",
|
|
||||||
"streamingDeleteButtonText": "删除目标",
|
|
||||||
"streamingDeleteDialogAreYouSure": "您确定要删除吗?",
|
|
||||||
"streamingDeleteDialogThisDestination": "这个目标",
|
|
||||||
"streamingDeleteDialogPermanentlyRemoved": "? 所有配置将被永久删除。",
|
|
||||||
"httpDestEditTitle": "编辑目标",
|
|
||||||
"httpDestAddTitle": "添加 HTTP 目标",
|
|
||||||
"httpDestEditDescription": "更新此 HTTP 事件流媒体目的地的配置。",
|
|
||||||
"httpDestAddDescription": "配置新的 HTTP 端点来接收您的组织事件。",
|
|
||||||
"S3DestEditTitle": "编辑目的地",
|
|
||||||
"S3DestAddTitle": "添加 S3 目的地",
|
|
||||||
"S3DestEditDescription": "更新此 S3 事件流目的地的配置。",
|
|
||||||
"S3DestAddDescription": "配置一个新的 Amazon S3(或兼容 S3 的)存储桶以接收您的组织事件。",
|
|
||||||
"s3DestTabSettings": "设置",
|
|
||||||
"s3DestTabFormat": "格式",
|
|
||||||
"s3DestNameLabel": "名称",
|
|
||||||
"s3DestNamePlaceholder": "我的 S3 目的地",
|
|
||||||
"s3DestAccessKeyIdLabel": "AWS 访问密钥 ID",
|
|
||||||
"s3DestSecretAccessKeyLabel": "AWS 秘密访问密钥",
|
|
||||||
"s3DestSecretAccessKeyPlaceholder": "您的 AWS 密钥",
|
|
||||||
"s3DestRegionLabel": "AWS 地区",
|
|
||||||
"s3DestBucketLabel": "存储桶名称",
|
|
||||||
"s3DestPrefixLabel": "密钥前缀(可选)",
|
|
||||||
"s3DestPrefixDescription": "每个对象密钥前加的可选路径前缀。对象存储在 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}。",
|
|
||||||
"s3DestEndpointLabel": "自定义端点(可选)",
|
|
||||||
"s3DestEndpointDescription": "替代 S3 端点用于 MinIO 或 Cloudflare R2 等兼容 S3 的存储。标准 AWS S3 留空。",
|
|
||||||
"s3DestGzipLabel": "Gzip 压缩",
|
|
||||||
"s3DestGzipDescription": "使用 gzip 压缩每个上传的对象。减少存储成本和上传大小。",
|
|
||||||
"s3DestFormatTitle": "文件格式",
|
|
||||||
"s3DestFormatDescription": "事件在每个上传对象内的序列化方式。",
|
|
||||||
"s3DestFormatJsonArrayDescription": "每个对象是事件记录的 JSON 数组。兼容大多数分析工具。",
|
|
||||||
"s3DestFormatNdjsonDescription": "每个对象每行包含一个 JSON 记录(换行分隔的 JSON)。兼容 Athena、BigQuery 和 Spark。",
|
|
||||||
"s3DestFormatCsvTitle": "CSV",
|
|
||||||
"s3DestFormatCsvDescription": "每个对象是带有标题行的 RFC-4180 CSV 文件。列名来自事件数据字段。",
|
|
||||||
"s3DestSaveChanges": "保存更改",
|
|
||||||
"s3DestCreateDestination": "创建目的地",
|
|
||||||
"s3DestUpdatedSuccess": "目的地更新成功",
|
|
||||||
"s3DestCreatedSuccess": "目的地创建成功",
|
|
||||||
"s3DestUpdateFailed": "更新目的地失败",
|
|
||||||
"s3DestCreateFailed": "创建目的地失败",
|
|
||||||
"datadogDestEditTitle": "编辑目的地",
|
|
||||||
"datadogDestAddTitle": "添加 Datadog 目的地",
|
|
||||||
"datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。",
|
|
||||||
"datadogDestAddDescription": "配置新的 Datadog 终端以接收您的组织事件。",
|
|
||||||
"httpDestTabSettings": "设置",
|
|
||||||
"httpDestTabHeaders": "信头",
|
|
||||||
"httpDestTabBody": "正文内容",
|
|
||||||
"httpDestTabLogs": "日志",
|
|
||||||
"httpDestNamePlaceholder": "我的 HTTP 目标",
|
|
||||||
"httpDestUrlLabel": "目标网址",
|
|
||||||
"httpDestUrlErrorHttpRequired": "URL 必须使用 http 或 https",
|
|
||||||
"httpDestUrlErrorHttpsRequired": "云端部署需要HTTPS",
|
|
||||||
"httpDestUrlErrorInvalid": "输入一个有效的 URL (例如,https://example.com/webhook)",
|
|
||||||
"httpDestAuthTitle": "认证",
|
|
||||||
"httpDestAuthDescription": "选择如何验证您的端点的请求。",
|
|
||||||
"httpDestAuthNoneTitle": "无身份验证",
|
|
||||||
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
|
|
||||||
"httpDestAuthBearerTitle": "持有者令牌",
|
|
||||||
"httpDestAuthBearerDescription": "在每个请求中添加授权:Bearer “<token>” 头。",
|
|
||||||
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
|
|
||||||
"httpDestAuthBasicTitle": "基本认证",
|
|
||||||
"httpDestAuthBasicDescription": "添加一个Authorization: Basic \"<凭据>\" 标头。 以用户名:密码形式提供凭据。",
|
|
||||||
"httpDestAuthBasicPlaceholder": "用户名:密码",
|
|
||||||
"httpDestAuthCustomTitle": "自定义标题",
|
|
||||||
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如,X-API 键)。",
|
|
||||||
"httpDestAuthCustomHeaderNamePlaceholder": "标题名称(例如X-API-键)",
|
|
||||||
"httpDestAuthCustomHeaderValuePlaceholder": "页眉值",
|
|
||||||
"httpDestCustomHeadersTitle": "自定义 HTTP 头",
|
|
||||||
"httpDestCustomHeadersDescription": "向每个输出请求添加自定义标题。用于静态令牌或自定义内容类型。默认情况下,内容类型:应用程序/json已发送。",
|
|
||||||
"httpDestNoHeadersConfigured": "未配置自定义头。单击\"添加头\"以添加一个。",
|
|
||||||
"httpDestHeaderNamePlaceholder": "标题名称",
|
|
||||||
"httpDestHeaderValuePlaceholder": "值",
|
|
||||||
"httpDestAddHeader": "添加标题",
|
|
||||||
"httpDestBodyTemplateTitle": "自定义实体模板",
|
|
||||||
"httpDestBodyTemplateDescription": "控制发送到您的端点的 JSON 有效载荷结构。如果禁用,将为每个事件发送一个 JSON 默认对象。",
|
|
||||||
"httpDestEnableBodyTemplate": "启用自定义实体模板",
|
|
||||||
"httpDestBodyTemplateLabel": "身体模板 (JSON)",
|
|
||||||
"httpDestBodyTemplateHint": "将模板变量用于您有效载荷中的参考事件字段。",
|
|
||||||
"httpDestPayloadFormatTitle": "有效载荷格式",
|
|
||||||
"httpDestPayloadFormatDescription": "事件如何序列化为每个请求实体。",
|
|
||||||
"httpDestFormatJsonArrayTitle": "JSON 数组",
|
|
||||||
"httpDestFormatJsonArrayDescription": "每批一个请求,实体是一个 JSON 数组。与大多数通用的 Web 钩子和数据兼容。",
|
|
||||||
"httpDestFormatNdjsonTitle": "NDJSON",
|
|
||||||
"httpDestFormatNdjsonDescription": "每批有一个请求,物体是换行符限制的 JSON --每行一个对象,不是外部数组。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。",
|
|
||||||
"httpDestFormatSingleTitle": "每个请求一个事件",
|
|
||||||
"httpDestFormatSingleDescription": "为每个事件单独发送一个 HTTP POST。仅用于无法处理批量的端点。",
|
|
||||||
"httpDestLogTypesTitle": "日志类型",
|
|
||||||
"httpDestLogTypesDescription": "选择转发到此目的地的日志类型。只有启用的日志类型才会被连续使用。",
|
|
||||||
"httpDestAccessLogsTitle": "访问日志",
|
|
||||||
"httpDestAccessLogsDescription": "资源访问尝试,包括已验证和拒绝的请求。",
|
|
||||||
"httpDestActionLogsTitle": "操作日志",
|
|
||||||
"httpDestActionLogsDescription": "组织内部用户采取的行政行动。",
|
|
||||||
"httpDestConnectionLogsTitle": "连接日志",
|
|
||||||
"httpDestConnectionLogsDescription": "站点和隧道连接事件,包括连接和断开连接。",
|
|
||||||
"httpDestRequestLogsTitle": "请求日志",
|
|
||||||
"httpDestRequestLogsDescription": "HTTP 请求代理资源日志,包括方法、路径和响应代码。",
|
|
||||||
"httpDestSaveChanges": "保存更改",
|
|
||||||
"httpDestCreateDestination": "创建目标",
|
|
||||||
"httpDestUpdatedSuccess": "目标已成功更新",
|
|
||||||
"httpDestCreatedSuccess": "目标创建成功",
|
|
||||||
"httpDestUpdateFailed": "更新目标失败",
|
|
||||||
"httpDestCreateFailed": "创建目标失败",
|
|
||||||
"followRedirects": "遵循重定向",
|
|
||||||
"followRedirectsDescription": "自动跟踪请求的 HTTP 重定向。",
|
|
||||||
"alertingErrorWebhookUrl": "请输入有效的 Webhook URL。",
|
|
||||||
"healthCheckStrategyHttp": "验证连接并检查 HTTP 响应状态。",
|
|
||||||
"healthCheckStrategyTcp": "只验证 TCP 连接性,不检查响应。",
|
|
||||||
"healthCheckStrategySnmp": "进行 SNMP get 请求以检查网络设备和基础架构的健康状况。",
|
|
||||||
"healthCheckStrategyIcmp": "使用 ICMP 回显请求(ping)检查资源是否可达并响应。",
|
|
||||||
"healthCheckTabStrategy": "策略",
|
|
||||||
"healthCheckTabConnection": "连接",
|
|
||||||
"healthCheckTabAdvanced": "高级",
|
|
||||||
"healthCheckStrategyNotAvailable": "此策略不可用。请联系销售以启用此功能。",
|
|
||||||
"uptime30d": "正常运行时间(30天)",
|
|
||||||
"idpAddActionCreateNew": "创建新的身份提供者",
|
|
||||||
"idpAddActionImportFromOrg": "从另一个组织导入",
|
|
||||||
"idpImportDialogTitle": "导入身份提供者",
|
|
||||||
"idpImportDialogDescription": "从您是管理员的组织中选择一个身份提供者。它将关联到本组织。",
|
|
||||||
"idpImportSearchPlaceholder": "按组织或提供者名称搜索……",
|
|
||||||
"idpImportEmpty": "未找到身份提供者。",
|
|
||||||
"idpImportedDescription": "身份提供者已成功导入。",
|
|
||||||
"idpDeleteGlobalQuestion": "您确定要永久删除此身份提供者吗?",
|
|
||||||
"idpDeleteGlobalDescription": "这将永久删除与其关联的所有组织中的身份提供者。",
|
|
||||||
"idpUnassociateTitle": "取消关联身份提供者",
|
|
||||||
"idpUnassociateQuestion": "您确定要将此身份提供者从此组织中取消关联吗?",
|
|
||||||
"idpUnassociateDescription": "与此身份提供者关联的所有用户将从该组织中移除,但身份提供者仍会继续存在于关联的其他组织中。",
|
|
||||||
"idpUnassociateConfirm": "确认取消关联身份提供者",
|
|
||||||
"idpUnassociateWarning": "此操作无法对该组织撤销。",
|
|
||||||
"idpUnassociatedDescription": "身份提供者已成功从该组织中取消关联",
|
|
||||||
"idpUnassociateMenu": "取消关联",
|
|
||||||
"idpDeleteAllOrgsMenu": "删除",
|
|
||||||
"publicIpEndpoint": "终端",
|
|
||||||
"lastTriggeredAt": "最后触发",
|
|
||||||
"reject": "拒绝",
|
|
||||||
"uptimeDaysAgo": "{count} 天前",
|
|
||||||
"uptimeToday": "今天",
|
|
||||||
"uptimeNoDataAvailable": "暂无数据",
|
|
||||||
"uptimeSuffix": "正常运行时间",
|
|
||||||
"uptimeDowntimeSuffix": "停机时间",
|
|
||||||
"uptimeTooltipUptimeLabel": "正常运行",
|
|
||||||
"uptimeTooltipDowntimeLabel": "停机",
|
|
||||||
"uptimeOngoing": "正在进行",
|
|
||||||
"uptimeNoMonitoringData": "无监控数据",
|
|
||||||
"uptimeNoData": "无数据",
|
|
||||||
"uptimeMiniBarDown": "停机",
|
|
||||||
"uptimeSectionTitle": "正常运行时间",
|
|
||||||
"uptimeSectionDescription": "过去 {days} 天的可用性",
|
|
||||||
"uptimeAddAlert": "添加警报",
|
|
||||||
"uptimeViewAlerts": "查看警报",
|
|
||||||
"uptimeCreateEmailAlert": "创建电子邮件警报",
|
|
||||||
"uptimeAlertDescriptionSite": "当此站点下线或恢复上线时,将通过电子邮件通知您。",
|
|
||||||
"uptimeAlertDescriptionResource": "当此资源下线或恢复上线时,将通过电子邮件通知您。",
|
|
||||||
"uptimeAlertNamePlaceholder": "警报名称",
|
|
||||||
"uptimeAdditionalEmails": "附加电子邮件",
|
|
||||||
"uptimeCreateAlert": "创建警报",
|
|
||||||
"uptimeAlertNoRecipients": "无收件人",
|
|
||||||
"uptimeAlertNoRecipientsDescription": "请至少添加一个用户、角色或电子邮件进行通知。",
|
|
||||||
"uptimeAlertCreated": "警报已创建",
|
|
||||||
"uptimeAlertCreatedDescription": "状态变化时将通知您。",
|
|
||||||
"uptimeAlertCreateFailed": "创建警报失败",
|
|
||||||
"webhookUrlLabel": "URL",
|
|
||||||
"webhookHeaderKeyPlaceholder": "关键字",
|
|
||||||
"webhookHeaderValuePlaceholder": "值",
|
|
||||||
"alertLabel": "警报",
|
|
||||||
"domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。",
|
|
||||||
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
|
|
||||||
"domainPickerWildcardCertWarningLink": "了解更多",
|
|
||||||
"health": "健康",
|
|
||||||
"domainPendingErrorTitle": "验证问题",
|
|
||||||
"memberPortalTitle": "资源",
|
|
||||||
"memberPortalDescription": "您在此组织中可以访问的资源",
|
|
||||||
"memberPortalSortBy": "排序依据……",
|
|
||||||
"memberPortalSortNameAsc": "名称 A-Z",
|
|
||||||
"memberPortalSortNameDesc": "名称 Z-A",
|
|
||||||
"memberPortalSortDomainAsc": "域名 A-Z",
|
|
||||||
"memberPortalSortDomainDesc": "域名 Z-A",
|
|
||||||
"memberPortalSortEnabledFirst": "启用优先",
|
|
||||||
"memberPortalSortDisabledFirst": "禁用优先",
|
|
||||||
"memberPortalRefresh": "刷新",
|
|
||||||
"memberPortalRefreshResources": "刷新资源",
|
|
||||||
"memberPortalFailedToLoad": "加载资源失败",
|
|
||||||
"memberPortalFailedToLoadDescription": "加载资源失败。请检查您的连接并再试一次。",
|
|
||||||
"memberPortalUnableToLoad": "无法加载资源",
|
|
||||||
"memberPortalTryAgain": "再试一次",
|
|
||||||
"memberPortalNoResourcesFound": "找不到资源",
|
|
||||||
"memberPortalNoResourcesAvailable": "无可用资源",
|
|
||||||
"memberPortalNoResourcesMatchSearch": "没有与\"{query}\"匹配的资源。尝试调整您的搜索词或清除搜索以查看所有资源。",
|
|
||||||
"memberPortalNoResourcesAccess": "您尚无访问任何资源的权限。请联系您的管理员获取所需资源的访问权限。",
|
|
||||||
"memberPortalClearSearch": "清除搜索",
|
|
||||||
"memberPortalPublicResources": "公共资源",
|
|
||||||
"memberPortalPublicResourcesDescription": "通过浏览器可访问的网络应用和服务",
|
|
||||||
"memberPortalCopiedToClipboard": "已复制到剪贴板",
|
|
||||||
"memberPortalCopiedUrlDescription": "资源 URL 已复制到您的剪贴板。",
|
|
||||||
"memberPortalOpenResource": "打开资源",
|
|
||||||
"memberPortalPrivateResources": "私有资源",
|
|
||||||
"memberPortalPrivateResourcesDescription": "通过客户端可访问的内部网络资源",
|
|
||||||
"memberPortalResourceDetails": "资源详情",
|
|
||||||
"memberPortalMode": "模式",
|
|
||||||
"memberPortalDestination": "目标",
|
|
||||||
"memberPortalAlias": "别名",
|
|
||||||
"memberPortalCopiedAliasDescription": "资源别名已复制到您的剪贴板。",
|
|
||||||
"memberPortalCopiedDestinationDescription": "资源目的地已复制到您的剪贴板。",
|
|
||||||
"memberPortalRequiresClientConnection": "需要客户端连接",
|
|
||||||
"memberPortalAuthMethods": "身份验证方法",
|
|
||||||
"memberPortalSso": "单一登录 (SSO)",
|
|
||||||
"memberPortalPasswordProtected": "密码保护",
|
|
||||||
"memberPortalPinCode": "PIN 码",
|
|
||||||
"memberPortalEmailWhitelist": "电子邮件白名单",
|
|
||||||
"memberPortalResourceDisabled": "资源已禁用",
|
|
||||||
"memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源",
|
|
||||||
"memberPortalPrevious": "上一页",
|
|
||||||
"memberPortalNext": "下一页"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2391
-2396
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user