Compare commits

..

14 Commits

Author SHA1 Message Date
Owen Schwartz 706f91e829 New translations en-us.json (Norwegian Bokmal) 2026-02-24 19:58:52 -08:00
Owen Schwartz 84c7111427 New translations en-us.json (Chinese Simplified) 2026-02-24 19:58:50 -08:00
Owen Schwartz 9512a4ee71 New translations en-us.json (Turkish) 2026-02-24 19:58:49 -08:00
Owen Schwartz 22f0eb0d59 New translations en-us.json (Russian) 2026-02-24 19:58:48 -08:00
Owen Schwartz 77987924af New translations en-us.json (Portuguese) 2026-02-24 19:58:46 -08:00
Owen Schwartz 0547a4fde3 New translations en-us.json (Polish) 2026-02-24 19:58:44 -08:00
Owen Schwartz dd53d90eb5 New translations en-us.json (Dutch) 2026-02-24 19:58:43 -08:00
Owen Schwartz f9ab1627fd New translations en-us.json (Korean) 2026-02-24 19:58:41 -08:00
Owen Schwartz f18ae7f16d New translations en-us.json (Italian) 2026-02-24 19:58:40 -08:00
Owen Schwartz 54f3c0a0c4 New translations en-us.json (German) 2026-02-24 19:58:38 -08:00
Owen Schwartz dc03bf8674 New translations en-us.json (Czech) 2026-02-24 19:58:37 -08:00
Owen Schwartz afc28713e3 New translations en-us.json (Bulgarian) 2026-02-24 19:58:35 -08:00
Owen Schwartz 9ac7b28455 New translations en-us.json (Spanish) 2026-02-24 19:58:33 -08:00
Owen Schwartz b13495f1e0 New translations en-us.json (French) 2026-02-24 19:58:32 -08:00
874 changed files with 18653 additions and 71359 deletions
-5
View File
@@ -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.
-7
View File
@@ -1,7 +0,0 @@
---
description:
alwaysApply: true
---
Proxy resources = public resources
Private resources = client resources = site resources
+2 -2
View File
@@ -28,9 +28,9 @@ LICENSE
CONTRIBUTING.md
dist
.git
server/migrations/
migrations/
config/
build.ts
tsconfig.json
Dockerfile*
drizzle.config.ts
migrations/
-1
View File
@@ -1 +0,0 @@
* @oschwartz10612 @miloschwartz
+112 -46
View File
@@ -29,7 +29,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
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
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Monitor storage space
run: |
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Monitor storage space
run: |
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Extract tag name
id: get-tag
@@ -264,9 +264,9 @@ jobs:
shell: bash
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: 1.25
go-version: 1.24
- name: Update version in package.json
run: |
@@ -289,17 +289,25 @@ jobs:
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
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
working-directory: install
run: |
make go-build-release \
PANGOLIN_VERSION=${{ env.TAG }} \
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
shell: bash
make go-build-release
- name: Upload artifacts from /install/bin
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: install-bin
path: install/bin/
@@ -407,25 +415,35 @@ jobs:
shell: bash
- 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:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
# cosign is used to sign and verify container images (key and keyless)
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
# Signatures are stored in the registry alongside the image.
- name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each image by digest using keyless (OIDC) and key-based signing,
# then verify both the public key signature and the keyless OIDC signature.
env:
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"
run: |
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
IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then
@@ -453,47 +471,95 @@ jobs:
)
fi
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Sign each image variant for both registries
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
echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
# Wrap the entire tag processing in error handling
(
set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
(
set -e
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}"
cosign sign --recursive "${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
) || TAG_FAILED=true
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
fi
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
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
# Report summary
echo ""
echo "=========================================="
echo "Sign Summary"
echo "Sign and Verify Summary"
echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag"
done
echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
echo ""
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
else
echo "✓ All images signed successfully!"
echo "✓ All images signed and verified successfully!"
fi
shell: bash
@@ -512,7 +578,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
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
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24'
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
skopeo --version
- name: Install cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Input check
run: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
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
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
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
@@ -54,7 +54,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download MaxMind GeoLite2 databases
env:
@@ -104,7 +104,7 @@ jobs:
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
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
@@ -145,7 +145,7 @@ jobs:
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
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
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
days-before-stale: 14
days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '24'
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Build Docker image pg
run: make dev-build-pg
+5 -15
View File
@@ -1,9 +1,8 @@
# FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
FROM node:24-alpine AS base
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 ./
@@ -24,20 +23,15 @@ RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run build:cli && \
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
RUN npm ci --omit=dev
# FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
FROM node:24-alpine AS runner
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/package.json ./package.json
@@ -57,16 +51,12 @@ COPY public ./public
# Copy MaxMind databases for SaaS builds
ARG BUILD=oss
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-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
ARG VERSION="dev"
ARG REVISION=""
+31 -36
View File
@@ -35,53 +35,43 @@
</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">
<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>
</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
- Get started for free with [Pangolin Cloud](https://app.pangolin.net/).
- 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.
- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin.
- 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
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
- **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.
| <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 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
### Connect remote networks with sites and NAT traversal
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.
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
### 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%" />
| <img width=500 /> | <img width=500 /> |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| **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> |
| **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> |
## Download Clients
@@ -95,16 +85,17 @@ Download the Pangolin client for your platform:
## 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
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
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
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
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
---
WireGuard® is a registered trademark of Jason A. Donenfeld.
+1 -1
View File
@@ -3,7 +3,7 @@
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
2. Send a detailed report to [security@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.
- Potential impact of the vulnerability.
+17
View File
@@ -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
}
}
+11
View File
@@ -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
}
+11
View File
@@ -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
}
+11
View File
@@ -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
}
+11
View File
@@ -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
}
+17
View File
@@ -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"]
}
}
+17
View File
@@ -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"]
}
}
+3
View File
@@ -0,0 +1,3 @@
meta {
name: API Keys
}
+18
View File
@@ -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"
}
}
+17
View File
@@ -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"
}
}
+17
View File
@@ -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"
}
}
+18
View File
@@ -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": ""
}
}
+18
View File
@@ -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!"
}
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: logout
type: http
seq: 3
}
post {
url: http://localhost:4000/api/v1/auth/logout
body: none
auth: none
}
+17
View File
@@ -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"
}
}
+19
View File
@@ -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"
}
}
+18
View File
@@ -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!"
}
}
+11
View File
@@ -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
}
+17
View File
@@ -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"
}
}
+15
View File
@@ -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
}
+22
View File
@@ -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"
}
}
+11
View File
@@ -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
}
+22
View File
@@ -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"
}
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: Generate OIDC URL
type: http
seq: 2
}
get {
url: http://localhost:3000/api/v1
body: none
auth: inherit
}
+3
View File
@@ -0,0 +1,3 @@
meta {
name: IDP
}
+11
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
meta {
name: Internal
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: Create Newt
type: http
seq: 2
}
get {
url: http://localhost:3000/api/v1/newt
body: none
auth: none
}
+18
View File
@@ -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"
}
}
+15
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
meta {
name: Olm
seq: 15
}
auth {
mode: inherit
}
+11
View File
@@ -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
}
+11
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: listResourcesByOrg
type: http
seq: 1
}
get {
url:
body: none
auth: none
}
+16
View File
@@ -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
}
+11
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: listSites
type: http
seq: 1
}
get {
url:
body: none
auth: none
}
+16
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: Test
type: http
seq: 2
}
get {
url: http://localhost:3000/api/v1
body: none
auth: inherit
}
+11
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: adminListUsers
type: http
seq: 2
}
get {
url: http://localhost:3000/api/v1/users
body: none
auth: none
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: adminRemoveUser
type: http
seq: 3
}
delete {
url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4
body: none
auth: none
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: getUser
type: http
seq: 1
}
get {
url:
body: none
auth: none
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "1",
"name": "Pangolin",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"presets": {
"requestType": "http",
"requestUrl": "http://localhost:3000/api/v1"
}
}
-28
View File
@@ -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);
}
}
};
-60
View File
@@ -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);
}
}
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { db, orgs } from "@server/db";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
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 yaml from "js-yaml";
+1 -133
View File
@@ -1,5 +1,5 @@
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 { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
@@ -129,15 +129,9 @@ export const rotateServerSecret: CommandModule<
console.log("\nReading encrypted data from database...");
const idpConfigs = await db.select().from(idpOidcConfig);
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 ${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
console.log("\nDecrypting and re-encrypting values...");
@@ -155,27 +149,8 @@ export const rotateServerSecret: CommandModule<
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 licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
// Process idpOidcConfig entries
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
console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => {
@@ -339,50 +250,10 @@ export const rotateServerSecret: CommandModule<
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 ${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
console.log("\nUpdating config file...");
@@ -399,9 +270,6 @@ export const rotateServerSecret: CommandModule<
console.log(`\nSummary:`);
console.log(` - OIDC IdP configurations: ${idpUpdates.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(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);
-4
View File
@@ -9,8 +9,6 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa";
yargs(hideBin(process.argv))
.scriptName("pangctl")
@@ -21,7 +19,5 @@ yargs(hideBin(process.argv))
.command(clearLicenseKeys)
.command(deleteClient)
.command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.demandCommand()
.help().argv;
-1
View File
@@ -1 +0,0 @@
*-journal
-6
View File
@@ -4,12 +4,6 @@ services:
image: fosrl/pangolin:latest
container_name: pangolin
restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes:
- ./config:/app/config
healthcheck:
-12
View File
@@ -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:
+2 -2
View File
@@ -7,8 +7,8 @@ services:
POSTGRES_DB: postgres # Default database name
POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: password # Default password (change for production!)
volumes:
- ./config/postgres:/var/lib/postgresql/data
# volumes:
# - ./config/postgres:/var/lib/postgresql/data
ports:
- "5432:5432" # Map host port 5432 to container port 5432
restart: no
+34 -17
View File
@@ -1,24 +1,41 @@
all: go-build-release
# 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)
all: update-versions go-build-release put-back
dev-all: dev-update-versions dev-build dev-clean
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 -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
rm -f bin/installer_linux_amd64
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
View File
@@ -99,6 +99,11 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) {
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 {
// Read source file
sourceData, err := os.ReadFile(sourceFile)
@@ -113,19 +118,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
}
// Parse source Docker Compose YAML
var sourceCompose map[string]any
var sourceCompose map[string]interface{}
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
}
// Parse destination Docker Compose YAML
var destCompose map[string]any
var destCompose map[string]interface{}
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
}
// Get services section from source
sourceServices, ok := sourceCompose["services"].(map[string]any)
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
if !ok {
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
destServices, ok := destCompose["services"].(map[string]any)
destServices, ok := destCompose["services"].(map[string]interface{})
if !ok {
// If services section doesn't exist, create it
destServices = make(map[string]any)
destServices = make(map[string]interface{})
destCompose["services"] = destServices
}
@@ -182,21 +187,17 @@ func backupConfig() error {
return nil
}
func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
buffer := new(bytes.Buffer)
encoder := yaml.NewEncoder(buffer)
encoder.SetIndent(indent)
if err := encoder.Encode(data); err != nil {
err := encoder.Encode(data)
if err != nil {
return nil, err
}
defer func() {
if cerr := encoder.Close(); cerr != nil && err == nil {
err = cerr
}
}()
defer encoder.Close()
return buffer.Bytes(), nil
}
@@ -208,7 +209,7 @@ func replaceInFile(filepath, oldStr, newStr string) error {
}
// 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
err = os.WriteFile(filepath, []byte(newContent), 0644)
@@ -227,28 +228,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error {
}
// Parse YAML into a generic map
var compose map[string]any
var compose map[string]interface{}
if err := yaml.Unmarshal(data, &compose); err != nil {
return fmt.Errorf("error parsing compose file: %w", err)
}
// Get services section
services, ok := compose["services"].(map[string]any)
services, ok := compose["services"].(map[string]interface{})
if !ok {
return fmt.Errorf("services section not found or invalid")
}
// Get traefik service
traefik, ok := services["traefik"].(map[string]any)
traefik, ok := services["traefik"].(map[string]interface{})
if !ok {
return fmt.Errorf("traefik service not found or invalid")
}
// Check volumes
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
for _, v := range existingVolumes {
if v.(string) == logVolume {
@@ -294,13 +295,13 @@ func MergeYAML(baseFile, overlayFile string) error {
}
// Parse base YAML into a map
var baseMap map[string]any
var baseMap map[string]interface{}
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
return fmt.Errorf("error parsing base YAML: %v", err)
}
// Parse overlay YAML into a map
var overlayMap map[string]any
var overlayMap map[string]interface{}
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
return fmt.Errorf("error parsing overlay YAML: %v", err)
}
@@ -323,8 +324,8 @@ func MergeYAML(baseFile, overlayFile string) error {
}
// mergeMap recursively merges two maps
func mergeMap(base, overlay map[string]any) map[string]any {
result := make(map[string]any)
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy all key-values from base map
for k, v := range base {
@@ -335,8 +336,8 @@ func mergeMap(base, overlay map[string]any) map[string]any {
for k, v := range overlay {
// If both maps have the same key and both values are maps, merge recursively
if baseVal, ok := base[k]; ok {
if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap {
if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap {
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
result[k] = mergeMap(baseMap, overlayMap)
continue
}
+2 -10
View File
@@ -81,19 +81,11 @@ entryPoints:
transport:
respondingTimeouts:
readTimeout: "30m"
http3:
advertisedPort: 443
http:
tls:
certResolver: "letsencrypt"
middlewares:
middlewares:
- crowdsec@file
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"
insecureSkipVerify: true
+3 -8
View File
@@ -4,12 +4,6 @@ services:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
container_name: pangolin
restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
reservations:
memory: 256m
volumes:
- ./config:/app/config
healthcheck:
@@ -38,14 +32,15 @@ services:
- 51820:51820/udp
- 21820:21820/udp
- 443:443
- 443:443/udp # For http3 QUIC if desired
- 80:80
{{end}}
traefik:
image: docker.io/traefik:v3.6
container_name: traefik
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:
- 443:443
- 80:80
@@ -40,8 +40,6 @@ entryPoints:
transport:
respondingTimeouts:
readTimeout: "30m"
http3:
advertisedPort: 443
http:
tls:
certResolver: "letsencrypt"
+6 -7
View File
@@ -144,13 +144,12 @@ func installDocker() error {
}
func startDockerService() error {
switch runtime.GOOS {
case "linux":
if runtime.GOOS == "linux" {
cmd := exec.Command("systemctl", "enable", "--now", "docker")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
case "darwin":
} else if runtime.GOOS == "darwin" {
// On macOS, Docker is usually started via the Docker Desktop application
fmt.Println("Please start Docker Desktop manually on macOS.")
return nil
@@ -303,7 +302,7 @@ func pullContainers(containerType SupportedContainer) error {
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.
@@ -326,7 +325,7 @@ func startContainers(containerType SupportedContainer) error {
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.
@@ -348,7 +347,7 @@ func stopContainers(containerType SupportedContainer) error {
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.
@@ -370,5 +369,5 @@ func restartContainer(container string, containerType SupportedContainer) error
return nil
}
return fmt.Errorf("unsupported container type: %s", containerType)
return fmt.Errorf("Unsupported container type: %s", containerType)
}
+11 -89
View File
@@ -6,13 +6,12 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func installCrowdsec(config Config, installDir string) error {
func installCrowdsec(config Config) error {
if err := stopContainers(config.InstallationContainerType); err != nil {
return fmt.Errorf("failed to stop containers: %v", err)
@@ -28,20 +27,9 @@ func installCrowdsec(config Config, installDir string) error {
os.Exit(1)
}
if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
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)
os.MkdirAll("config/crowdsec/db", 0755)
os.MkdirAll("config/crowdsec/acquis.d", 0755)
os.MkdirAll("config/traefik/logs", 0755)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err)
@@ -165,34 +153,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
}
// Parse YAML into a generic map
var compose map[string]any
var compose map[string]interface{}
if err := yaml.Unmarshal(data, &compose); err != nil {
return fmt.Errorf("error parsing compose file: %w", err)
}
// Get services section
services, ok := compose["services"].(map[string]any)
services, ok := compose["services"].(map[string]interface{})
if !ok {
return fmt.Errorf("services section not found or invalid")
}
// Get traefik service
traefik, ok := services["traefik"].(map[string]any)
traefik, ok := services["traefik"].(map[string]interface{})
if !ok {
return fmt.Errorf("traefik service not found or invalid")
}
// Get dependencies
dependsOn, ok := traefik["depends_on"].(map[string]any)
dependsOn, ok := traefik["depends_on"].(map[string]interface{})
if ok {
// Append the new block for crowdsec
dependsOn["crowdsec"] = map[string]any{
dependsOn["crowdsec"] = map[string]interface{}{
"condition": "service_healthy",
}
} else {
// No dependencies exist, create it
traefik["depends_on"] = map[string]any{
"crowdsec": map[string]any{
traefik["depends_on"] = map[string]interface{}{
"crowdsec": map[string]interface{}{
"condition": "service_healthy",
},
}
@@ -211,69 +199,3 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
fmt.Println("Added dependency of crowdsec to traefik")
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
View File
@@ -1,38 +1,10 @@
module installer
go 1.25.0
go 1.24.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.42.0
golang.org/x/term v0.39.0
gopkg.in/yaml.v3 v3.0.1
)
require (
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.43.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
require golang.org/x/sys v0.40.0 // indirect
+4 -77
View File
@@ -1,80 +1,7 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+66 -182
View File
@@ -1,208 +1,92 @@
package main
import (
"errors"
"bufio"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"github.com/charmbracelet/huh"
"golang.org/x/term"
)
// pangolinTheme is the custom theme using brand colors
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
func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
if defaultValue != "" {
title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue)
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
} else {
fmt.Print(prompt + ": ")
}
input := huh.NewInput().
Title(title).
Value(&value)
// 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
})
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return defaultValue
}
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
return input
}
func readPassword(prompt string) string {
var value string
func readStringNoDefault(reader *bufio.Reader, prompt string) 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 {
input := huh.NewInput().
Title(prompt).
Value(&value).
EchoMode(huh.EchoModePassword).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("password is required")
}
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
input := readString(reader, prompt+" (yes/no)", defaultStr)
lower := strings.ToLower(input)
if lower == "yes" {
return true
} else if lower == "no" {
return false
} else {
fmt.Println("Please enter 'yes' or 'no'.")
}
}
}
func readBool(prompt string, defaultValue bool) bool {
var value = defaultValue
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"
func readBoolNoDefault(reader *bufio.Reader, prompt string) bool {
for {
input := readStringNoDefault(reader, prompt+" (yes/no)")
lower := strings.ToLower(input)
if lower == "yes" {
return true
} else if lower == "no" {
return false
} else {
fmt.Println("Please enter 'yes' or 'no'.")
}
fmt.Printf("%s: %s\n", prompt, answer)
}
return value
}
func readBoolNoDefault(prompt string) bool {
var value bool
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)
}
func readInt(reader *bufio.Reader, prompt string, defaultValue int) int {
input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue))
if input == "" {
return defaultValue
}
result, err := strconv.Atoi(value)
if err != nil {
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
value := defaultValue
fmt.Sscanf(input, "%d", &value)
return value
}
+93 -215
View File
@@ -1,35 +1,30 @@
package main
import (
"crypto/rand"
"bufio"
"embed"
"encoding/base64"
"fmt"
"io"
"io/fs"
"crypto/rand"
"encoding/base64"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"text/template"
"time"
)
// Version variables injected at build time via -ldflags
var (
pangolinVersion string
gerbilVersion string
badgerVersion string
)
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
func loadVersions(config *Config) {
config.PangolinVersion = pangolinVersion
config.GerbilVersion = gerbilVersion
config.BadgerVersion = badgerVersion
config.PangolinVersion = "replaceme"
config.GerbilVersion = "replaceme"
config.BadgerVersion = "replaceme"
}
//go:embed config/*
@@ -87,19 +82,14 @@ func main() {
}
}
reader := bufio.NewReader(os.Stdin)
var config Config
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
if _, err := os.Stat("config/config.yml"); err != nil {
config = collectUserInput()
config = collectUserInput(reader)
loadVersions(&config)
config.DoCrowdsecInstall = false
@@ -112,10 +102,7 @@ func main() {
os.Exit(1)
}
if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil {
fmt.Printf("Error moving docker-compose.yml: %v\n", err)
os.Exit(1)
}
moveFile("config/docker-compose.yml", "docker-compose.yml")
fmt.Println("\nConfiguration files created successfully!")
@@ -130,17 +117,13 @@ func main() {
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 readBool("Docker is not installed. Would you like to install it?", true) {
if err := installDocker(); err != nil {
fmt.Printf("Error installing Docker: %v\n", err)
return
}
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker()
// try to start docker service but ignore errors
if err := startDockerService(); err != nil {
fmt.Println("Error starting Docker service:", err)
@@ -149,7 +132,7 @@ func main() {
}
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
fmt.Println("Waiting for Docker to start...")
for range 5 {
for i := 0; i < 5; i++ {
if isDockerRunning() {
fmt.Println("Docker is running!")
break
@@ -184,7 +167,7 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
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 {
fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.")
@@ -192,7 +175,7 @@ func main() {
}
} else {
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 {
fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.")
@@ -209,11 +192,11 @@ func main() {
if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===")
// 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.")
// 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 == "" {
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
if err != nil {
@@ -242,8 +225,8 @@ func main() {
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
if !readBool("Are these values correct?", true) {
config = collectUserInput()
if !readBool(reader, "Are these values correct?", true) {
config = collectUserInput(reader)
}
}
@@ -252,14 +235,14 @@ func main() {
if detectedType == Undefined {
// If detection fails, prompt the user
fmt.Println("Unable to detect container type from existing installation.")
config.InstallationContainerType = podmanOrDocker()
config.InstallationContainerType = podmanOrDocker(reader)
} else {
config.InstallationContainerType = detectedType
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
}
config.DoCrowdsecInstall = true
err := installCrowdsec(config, installDir)
err := installCrowdsec(config)
if err != nil {
fmt.Printf("Error installing CrowdSec: %v\n", err)
return
@@ -294,119 +277,8 @@ func main() {
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
}
func hasExistingInstall(dir string) bool {
configPath := filepath.Join(dir, "config", "config.yml")
_, 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")
func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
chosenContainer := Docker
if strings.EqualFold(inputContainer, "docker") {
@@ -418,8 +290,7 @@ func podmanOrDocker() SupportedContainer {
os.Exit(1)
}
switch chosenContainer {
case Podman:
if chosenContainer == Podman {
if !isPodmanInstalled() {
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)
@@ -428,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 {
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.")
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 os.Geteuid() != 0 {
fmt.Println("You need to run the installer as root for such a configuration.")
@@ -440,7 +311,7 @@ func podmanOrDocker() SupportedContainer {
// 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 {
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
os.Exit(1)
}
} else {
@@ -450,7 +321,7 @@ func podmanOrDocker() SupportedContainer {
fmt.Println("Unprivileged ports have been configured.")
}
case Docker:
} else if chosenContainer == Docker {
// check if docker is not installed and the user is root
if !isDockerInstalled() {
if os.Geteuid() != 0 {
@@ -465,7 +336,7 @@ func podmanOrDocker() SupportedContainer {
fmt.Println("The installer will not be able to run docker commands without running it as root.")
os.Exit(1)
}
default:
} else {
// This shouldn't happen unless there's a third container runtime.
os.Exit(1)
}
@@ -473,35 +344,35 @@ func podmanOrDocker() SupportedContainer {
return chosenContainer
}
func collectUserInput() Config {
func collectUserInput(reader *bufio.Reader) Config {
config := Config{}
// 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
defaultDashboardDomain := ""
if config.BaseDomain != "" {
defaultDashboardDomain = "pangolin." + config.BaseDomain
}
config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true)
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
// 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 {
config.EmailSMTPHost = readString("Enter SMTP host", "")
config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587)
config.EmailSMTPUser = readString("Enter SMTP username", "")
config.EmailSMTPPass = readPassword("Enter SMTP password")
config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "")
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
config.EmailNoReply = readString(reader, "Enter no-reply email address (often the same as SMTP username)", "")
}
// Validate required fields
@@ -522,8 +393,8 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
@@ -534,23 +405,15 @@ func collectUserInput() Config {
}
func createConfigFiles(config Config) error {
if err := os.MkdirAll("config", 0755); err != nil {
return fmt.Errorf("failed to create config directory: %v", err)
}
if err := os.MkdirAll("config/letsencrypt", 0755); err != nil {
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)
}
os.MkdirAll("config", 0755)
os.MkdirAll("config/letsencrypt", 0755)
os.MkdirAll("config/db", 0755)
os.MkdirAll("config/logs", 0755)
// Walk through all embedded files
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
if walkErr != nil {
return walkErr
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root fs directory itself
@@ -601,11 +464,7 @@ func createConfigFiles(config Config) error {
if err != nil {
return fmt.Errorf("failed to create %s: %v", path, err)
}
defer func() {
if cerr := outFile.Close(); cerr != nil && err == nil {
err = cerr
}
}()
defer outFile.Close()
// Execute template
if err := tmpl.Execute(outFile, config); err != nil {
@@ -621,26 +480,18 @@ func createConfigFiles(config Config) error {
return nil
}
func copyFile(src, dst string) (err error) {
func copyFile(src, dst string) error {
source, err := os.Open(src)
if err != nil {
return err
}
defer func() {
if cerr := source.Close(); cerr != nil && err == nil {
err = cerr
}
}()
defer source.Close()
destination, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := destination.Close(); cerr != nil && err == nil {
err = cerr
}
}()
defer destination.Close()
_, err = io.Copy(destination, source)
return err
@@ -711,24 +562,22 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
fmt.Println("To get your setup token, you need to:")
fmt.Println("")
fmt.Println("1. Start the containers")
switch containerType {
case Docker:
if containerType == Docker {
fmt.Println(" docker compose up -d")
case Podman:
} else if containerType == Podman {
fmt.Println(" podman-compose up -d")
} else {
}
fmt.Println("")
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
fmt.Println("")
fmt.Println("3. Check the container logs for the setup token")
switch containerType {
case Docker:
if containerType == Docker {
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'")
} else {
}
fmt.Println("")
fmt.Println("4. Look for output like")
fmt.Println(" === SETUP TOKEN GENERATED ===")
@@ -752,6 +601,32 @@ func generateRandomSecretKey() string {
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.
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
@@ -764,7 +639,10 @@ func checkPortsAvailable(port int) error {
addr := fmt.Sprintf(":%d", port)
ln, err := net.Listen("tcp", addr)
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 {
fmt.Fprintf(os.Stderr,
-51
View File
@@ -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
}
-137
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+20 -643
View File
File diff suppressed because it is too large Load Diff
+21 -644
View File
File diff suppressed because it is too large Load Diff
+43 -666
View File
File diff suppressed because it is too large Load Diff
+21 -644
View File
File diff suppressed because it is too large Load Diff
+21 -644
View File
File diff suppressed because it is too large Load Diff
+63 -686
View File
File diff suppressed because it is too large Load Diff
+21 -644
View File
File diff suppressed because it is too large Load Diff
+20 -643
View File
File diff suppressed because it is too large Load Diff
+20 -643
View File
File diff suppressed because it is too large Load Diff
+20 -643
View File
File diff suppressed because it is too large Load Diff
+21 -644
View File
File diff suppressed because it is too large Load Diff
+23 -646
View File
File diff suppressed because it is too large Load Diff
+21 -644
View File
File diff suppressed because it is too large Load Diff
+19 -642
View File
@@ -1,8 +1,4 @@
{
"contactSalesEnable": "联系销售以启用此功能。",
"contactSalesBookDemo": "预订演示",
"contactSalesOr": "或",
"contactSalesContactUs": "联系我们",
"setupCreate": "创建组织、站点和资源",
"headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。",
"headerAuthCompatibility": "扩展兼容性",
@@ -23,18 +19,6 @@
"componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。",
"dismiss": "忽略",
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
"trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。",
"trialBannerExpired": "您的试用已到期。立即升级以恢复访问。",
"billingTrialBannerTitle": "免费试用激活中",
"billingTrialBannerDescription": "您目前正在商用层进行免费试用。试用结束后,您的账户将自动回到基础层功能和限制。可随时升级以保持当前计划的功能访问。",
"billingTrialBannerUpgrade": "立即升级",
"billingTrialBadge": "免费试用",
"trialActive": "免费试用中",
"trialExpired": "试用到期",
"trialHasEnded": "您的试用已结束。",
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
"trialDaysLeftShort": "试用期剩余 {days} 天",
"trialGoToBilling": "转到账单页面",
"subscriptionViolationViewBilling": "查看计费",
"componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。",
"componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。",
@@ -97,8 +81,6 @@
"siteConfirmCopy": "我已经复制了配置信息",
"searchSitesProgress": "搜索站点...",
"siteAdd": "添加站点",
"sitesTableViewPublicResources": "查看公共资源",
"sitesTableViewPrivateResources": "查看私有资源",
"siteInstallNewt": "安装 Newt",
"siteInstallNewtDescription": "在您的系统中运行 Newt",
"WgConfiguration": "WireGuard 配置",
@@ -116,21 +98,6 @@
"siteUpdatedDescription": "网站已更新。",
"siteGeneralDescription": "配置此站点的常规设置",
"siteSettingDescription": "配置站点设置",
"siteResourcesTab": "资源",
"siteResourcesNoneOnSite": "此站点尚无公开或私人资源。",
"siteResourcesSectionPublic": "公共资源",
"siteResourcesSectionPrivate": "私有资源",
"siteResourcesSectionPublicDescription": "通过域名或端口公开的资源。",
"siteResourcesSectionPrivateDescription": "通过站点可在您的私有网络上访问的资源。",
"siteResourcesViewAllPublic": "查看所有资源",
"siteResourcesViewAllPrivate": "查看所有资源",
"siteResourcesDialogDescription": "此站点的公开和私有资源概览。",
"siteResourcesShowMore": "显示更多",
"siteResourcesPermissionDenied": "您无权列出这些资源。",
"siteResourcesEmptyPublic": "尚无针对该站点的公共资源。",
"siteResourcesEmptyPrivate": "尚无与此站点关联的私有资源。",
"siteResourcesHowToAccess": "如何访问",
"siteResourcesTargetsOnSite": "此站点上的目标",
"siteSetting": "{siteName} 设置",
"siteNewtTunnel": "新站点 (推荐)",
"siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。",
@@ -156,10 +123,6 @@
"shareErrorDeleteMessage": "删除链接时出错",
"shareDeleted": "链接已删除",
"shareDeletedDescription": "链接已删除",
"shareDelete": "删除共享链接",
"shareDeleteConfirm": "确认删除共享链接",
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
"accessToken": "访问令牌",
"usageExamples": "用法示例",
@@ -185,11 +148,6 @@
"createLink": "创建链接",
"resourcesNotFound": "找不到资源",
"resourceSearch": "搜索资源",
"machineSearch": "搜索机",
"machinesSearch": "搜索机器客户端...",
"machineNotFound": "未找到任何机",
"userDeviceSearch": "搜索用户设备",
"userDevicesSearch": "搜索用户设备...",
"openMenu": "打开菜单",
"resource": "资源",
"title": "标题",
@@ -217,7 +175,6 @@
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
"resourceRaw": "TCP/UDP 资源",
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
"resourceRawDescriptionCloud": "正在使用端口号使用 TCP/UDP 代理请求。需要站点连接到远程节点。",
"resourceCreate": "创建资源",
"resourceCreateDescription": "按照下面的步骤创建新资源",
"resourceSeeAll": "查看所有资源",
@@ -304,11 +261,8 @@
"orgMissing": "缺少组织 ID",
"orgMissingMessage": "没有组织ID,无法重新生成邀请。",
"accessUsersManage": "管理用户",
"accessUserManage": "管理用户",
"accessUsersDescription": "邀请和管理访问此组织的用户",
"accessUsersSearch": "搜索用户...",
"accessUsersRoleFilterCount": "{count, plural, other {# 角色}}",
"accessUsersRoleFilterClear": "清除角色过滤器",
"accessUserCreate": "创建用户",
"accessUserRemove": "删除用户",
"username": "用户名",
@@ -368,54 +322,6 @@
"apiKeysDelete": "删除 API 密钥",
"apiKeysManage": "管理 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} 设置",
"userTitle": "管理所有用户",
"userDescription": "查看和管理系统中的所有用户",
@@ -445,10 +351,6 @@
"licenseErrorKeyActivate": "激活许可证密钥失败",
"licenseErrorKeyActivateDescription": "激活许可证密钥时出错。",
"licenseAbout": "关于许可协议",
"licenseBannerTitle": "启用您的企业许可证",
"licenseBannerDescription": "为您自行托管的Pangolin实例解锁企业功能。购买许可证密钥以激活高级功能,然后在下方添加。",
"licenseBannerGetLicense": "获取许可证",
"licenseBannerViewDocs": "查看文档",
"communityEdition": "社区版",
"licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。",
"licenseKeyActivated": "授权密钥已激活",
@@ -527,12 +429,6 @@
"userMessageOrgRemove": "一旦删除,这个用户将不再能够访问组织。 你总是可以稍后重新邀请他们,但他们需要再次接受邀请。",
"userRemoveOrgConfirm": "确认删除用户",
"userRemoveOrg": "从组织中删除用户",
"userQuestionOrgRemoveSelf": "你确定要将自己从这个组织中移除吗?",
"userMessageOrgRemoveSelf": "你将立即失去访问权限。管理员稍后可以再次邀请你,但你需要接受新的邀请。",
"userRemoveOrgConfirmSelf": "确认删除我自己",
"userRemoveOrgSelf": "将自己从组织中移除",
"userRemoveOrgSelfWarning": "你将立即失去对此组织的访问权限。",
"userRemoveOrgConfirmPhraseSelf": "从组织中移除我自己",
"users": "用户",
"accessRoleMember": "成员",
"accessRoleOwner": "所有者",
@@ -541,11 +437,6 @@
"emailInvalid": "无效的电子邮件地址",
"inviteValidityDuration": "请选择持续时间",
"accessRoleSelectPlease": "请选择一个角色",
"removeOwnAdminRoleConfirmTitle": "移除你的管理员权限?",
"removeOwnAdminRoleConfirmDescription": "保存后,你将不再拥有该组织的管理员权限。如果需要,其他管理员可以恢复访问。",
"removeOwnAdminRoleConfirmButton": "移除我的管理员访问权限",
"removeOwnAdminRoleConfirmPhrase": "移除我的管理员访问",
"ownerMustRetainAdminRole": "组织所有者必须保留至少一个管理员角色。",
"usernameRequired": "必须输入用户名",
"idpSelectPlease": "请选择身份提供商",
"idpGenericOidc": "通用的 OAuth2/OIDC 提供商。",
@@ -617,12 +508,9 @@
"userSaved": "用户已保存",
"userSavedDescription": "用户已更新。",
"autoProvisioned": "自动设置",
"autoProvisionSettings": "自动提供设置",
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
"accessControlsDescription": "管理此用户在组织中可以访问和做什么",
"accessControlsSubmit": "保存访问控制",
"singleRolePerUserPlanNotice": "您的计划仅支持每个用户一个角色。",
"singleRolePerUserEditionNotice": "此版本仅支持每个用户一个角色。",
"roles": "角色",
"accessUsersRoles": "管理用户和角色",
"accessUsersRolesDescription": "邀请用户加入角色来管理访问组织",
@@ -630,7 +518,7 @@
"createdAt": "创建于",
"proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。",
"proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。",
"proxyEnableSSL": "启用 TLS",
"proxyEnableSSL": "启用 SSL",
"proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。",
"target": "Target",
"configureTarget": "配置目标",
@@ -673,15 +561,12 @@
"targetNoOneDescription": "在上面添加多个目标将启用负载平衡。",
"targetsSubmit": "保存目标",
"addTarget": "添加目标",
"proxyMultiSiteRoundRobinNodeHelp": "轮询路由在未连接到相同节点的站点之间将不起作用,但故障转移会生效。",
"targetErrorInvalidIp": "无效的 IP 地址",
"targetErrorInvalidIpDescription": "请输入有效的IP地址或主机名",
"targetErrorInvalidPort": "无效的端口",
"targetErrorInvalidPortDescription": "请输入有效的端口号",
"targetErrorNoSite": "没有选择站点",
"targetErrorNoSiteDescription": "请选择目标站点",
"targetTargetsCleared": "目标已清除",
"targetTargetsClearedDescription": "所有目标已从此资源中移除",
"targetCreated": "目标已创建",
"targetCreatedDescription": "目标已成功创建",
"targetErrorCreate": "创建目标失败",
@@ -764,7 +649,7 @@
"resourcesUsersRolesAccess": "基于用户和角色的访问控制",
"resourcesErrorUpdate": "切换资源失败",
"resourcesErrorUpdateDescription": "更新资源时出错",
"access": "访问权限",
"access": "Access",
"accessControl": "访问控制",
"shareLink": "{resource} 的分享链接",
"resourceSelect": "选择资源",
@@ -783,7 +668,6 @@
"newtEndpoint": "Endpoint",
"newtId": "ID",
"newtSecretKey": "密钥",
"newtVersion": "版本",
"architecture": "架构",
"sites": "站点",
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
@@ -951,7 +835,6 @@
"idpDisplayName": "此身份提供商的显示名称",
"idpAutoProvisionUsers": "自动提供用户",
"idpAutoProvisionUsersDescription": "如果启用,用户将在首次登录时自动在系统中创建,并且能够映射用户到角色和组织。",
"idpAutoProvisionConfigureAfterCreate": "您可以在创建身份提供者后配置自动配置设置。",
"licenseBadge": "EE",
"idpType": "提供者类型",
"idpTypeDescription": "选择您想要配置的身份提供者类型",
@@ -1003,7 +886,7 @@
"defaultMappingsRole": "默认角色映射",
"defaultMappingsRoleDescription": "此表达式的结果必须返回组织中定义的角色名称作为字符串。",
"defaultMappingsOrg": "默认组织映射",
"defaultMappingsOrgDescription": "设置时,此表达式必须返回组织IDtrue才能用户访问组织。如果未设置,定义角色映射就足够了:只要在组织内可以为用户找出有效角色映射,用户就被允许进入。",
"defaultMappingsOrgDescription": "此表达式必须返回 组织IDtrue 才能允许用户访问组织。",
"defaultMappingsSubmit": "保存默认映射",
"orgPoliciesEdit": "编辑组织策略",
"org": "组织",
@@ -1218,12 +1101,6 @@
"actionGetUser": "获取用户",
"actionGetOrgUser": "获取组织用户",
"actionListOrgDomains": "列出组织域",
"actionGetDomain": "获取域",
"actionCreateOrgDomain": "创建域",
"actionUpdateOrgDomain": "更新域",
"actionDeleteOrgDomain": "删除域",
"actionGetDNSRecords": "获取 DNS 记录",
"actionRestartOrgDomain": "重新启动域",
"actionCreateSite": "创建站点",
"actionDeleteSite": "删除站点",
"actionGetSite": "获取站点",
@@ -1235,7 +1112,6 @@
"setupTokenDescription": "从服务器控制台输入设置令牌。",
"setupTokenRequired": "需要设置令牌",
"actionUpdateSite": "更新站点",
"actionResetSiteBandwidth": "重置组织带宽",
"actionListSiteRoles": "允许站点角色列表",
"actionCreateResource": "创建资源",
"actionDeleteResource": "删除资源",
@@ -1265,7 +1141,6 @@
"actionRemoveUser": "删除用户",
"actionListUsers": "列出用户",
"actionAddUserRole": "添加用户角色",
"actionSetUserOrgRoles": "设置用户角色",
"actionGenerateAccessToken": "生成访问令牌",
"actionDeleteAccessToken": "删除访问令牌",
"actionListAccessTokens": "访问令牌",
@@ -1310,7 +1185,6 @@
"actionViewLogs": "查看日志",
"noneSelected": "未选择",
"orgNotFound2": "未找到组织。",
"search": "搜索…",
"searchPlaceholder": "搜索...",
"emptySearchOptions": "未找到选项",
"create": "创建",
@@ -1383,7 +1257,6 @@
"sidebarRoles": "角色",
"sidebarShareableLinks": "链接",
"sidebarApiKeys": "API密钥",
"sidebarProvisioning": "置备中",
"sidebarSettings": "设置",
"sidebarAllUsers": "所有用户",
"sidebarIdentityProviders": "身份提供商",
@@ -1395,167 +1268,10 @@
"sidebarGeneral": "管理",
"sidebarLogAndAnalytics": "日志与分析",
"sidebarBluePrints": "蓝图",
"sidebarAlerting": "告警",
"sidebarHealthChecks": "健康检查",
"sidebarOrganization": "组织",
"sidebarManagement": "管理",
"sidebarBillingAndLicenses": "帐单和许可证",
"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": "蓝图",
"blueprintsDescription": "应用声明配置并查看先前运行的",
"blueprintAdd": "添加蓝图",
@@ -1617,8 +1333,7 @@
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
"createAdminAccount": "创建管理员帐户",
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
"certificateStatus": "证书",
"certificateStatusAutoRefreshHint": "状态自动刷新。",
"certificateStatus": "证书状态",
"loading": "加载中",
"loadingAnalytics": "加载分析",
"restart": "重启",
@@ -1687,7 +1402,6 @@
"pangolinUpdateAvailableReleaseNotes": "查看发布说明",
"newtUpdateAvailable": "更新可用",
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
"pangolinNodeUpdateAvailableInfo": "新版本的 Pangolin Node 已可用。请更新到最新版本以获得最佳体验。",
"domainPickerEnterDomain": "域名",
"domainPickerPlaceholder": "example.com",
"domainPickerDescription": "输入资源的完整域名以查看可用选项。",
@@ -1705,7 +1419,6 @@
"domainPickerNamespace": "命名空间:{namespace}",
"domainPickerShowMore": "显示更多",
"regionSelectorTitle": "选择区域",
"domainPickerRemoteExitNodeWarning": "当站点连接到远程退出节点时不支持所提供的域。为了资源可在远程节点上使用,请使用自定义域名。",
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
"regionSelectorPlaceholder": "选择一个区域",
"regionSelectorComingSoon": "即将推出",
@@ -1927,7 +1640,6 @@
"configureHealthCheck": "配置健康检查",
"configureHealthCheckDescription": "为 {target} 设置健康监控",
"enableHealthChecks": "启用健康检查",
"healthCheckDisabledStateDescription": "禁用后,站点不会进行健康检查,状态将被视为未知。",
"enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。",
"healthScheme": "方法",
"healthSelectScheme": "选择方法",
@@ -1957,10 +1669,10 @@
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
"sshSudo": "允许Sudo",
"sshSudoCommands": "Sudo 命令",
"sshSudoCommandsDescription": "逗号分隔的用户允许使用 sudo 运行的命令列表。",
"sshSudoCommandsDescription": "允许用户使用 sudo 运行的命令列表。",
"sshCreateHomeDir": "创建主目录",
"sshUnixGroups": "Unix 组",
"sshUnixGroupsDescription": "用逗号分隔了Unix组,将用户添加到目标主机。",
"sshUnixGroupsDescription": "将用户添加到目标主机的Unix组。",
"retryAttempts": "重试次数",
"expectedResponseCodes": "期望响应代码",
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
@@ -1977,20 +1689,9 @@
"healthCheckIntervalMin": "检查间隔必须至少为 5 秒",
"healthCheckTimeoutMin": "超时必须至少为 1 秒",
"healthCheckRetryMin": "重试次数必须至少为 1 次",
"healthCheckMode": "检查模式",
"healthCheckStrategy": "策略",
"healthCheckModeDescription": "TCP 模式仅验证连接性。HTTP 模式验证 HTTP 响应。",
"healthyThreshold": "正常阈值",
"healthyThresholdDescription": "标记为正常之前所需的连续成功次数。",
"unhealthyThreshold": "不正常阈值",
"unhealthyThresholdDescription": "标记为不正常之前所需的连续失败次数。",
"healthCheckHealthyThresholdMin": "健康阈值至少为 1",
"healthCheckUnhealthyThresholdMin": "不健康阈值至少为 1",
"httpMethod": "HTTP 方法",
"selectHttpMethod": "选择 HTTP 方法",
"domainPickerSubdomainLabel": "子域名",
"domainPickerWildcard": "通配符",
"domainPickerWildcardPaidOnly": "通配符子域是付费功能。请升级以使用此功能。",
"domainPickerBaseDomainLabel": "根域名",
"domainPickerSearchDomains": "搜索域名...",
"domainPickerNoDomainsFound": "未找到域名",
@@ -2016,12 +1717,12 @@
"resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。",
"resourcesTableClients": "客户端",
"resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。",
"resourcesTableNoTargets": "没有目标",
"resourcesTableHealthy": "健康的",
"resourcesTableDegraded": "降级",
"resourcesTableUnhealthy": "不健康",
"resourcesTableOffline": "离线的",
"resourcesTableUnknown": "未知的",
"resourcesTableNotMonitored": "未监视的",
"resourcesTableNoTargets": "无目标",
"editInternalResourceDialogEditClientResource": "编辑私有资源",
"editInternalResourceDialogUpdateResourceProperties": "更新{resourceName}的资源配置和访问控制。",
"editInternalResourceDialogResourceProperties": "资源属性",
@@ -2047,11 +1748,6 @@
"editInternalResourceDialogModePort": "端口",
"editInternalResourceDialogModeHost": "主机",
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "方案",
"editInternalResourceDialogEnableSsl": "启用 TLS",
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
"editInternalResourceDialogDestination": "目标",
"editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
"editInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。",
@@ -2067,7 +1763,6 @@
"createInternalResourceDialogName": "名称",
"createInternalResourceDialogSite": "站点",
"selectSite": "选择站点...",
"multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}",
"noSitesFound": "未找到站点。",
"createInternalResourceDialogProtocol": "协议",
"createInternalResourceDialogTcp": "TCP",
@@ -2096,19 +1791,11 @@
"createInternalResourceDialogModePort": "端口",
"createInternalResourceDialogModeHost": "主机",
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "方案",
"createInternalResourceDialogScheme": "方案",
"createInternalResourceDialogEnableSsl": "启用 TLS",
"createInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
"createInternalResourceDialogDestination": "目标",
"createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。",
"createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。",
"internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案",
"internalResourceHttpPortRequired": "HTTP 资源需要目的端口",
"siteConfiguration": "配置",
"siteAcceptClientConnections": "接受客户端连接",
"siteAcceptClientConnectionsDescription": "允许用户设备和客户端访问此站点上的资源。这可以稍后更改。",
@@ -2194,46 +1881,12 @@
"exitNode": "出口节点",
"country": "国家",
"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": {
"title": "托管自托管",
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
"introTitle": "托管自托管的潘戈林公司",
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 - - 您的隧道、TLS 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
"benefitSimplerOperations": {
"title": "简单的操作",
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
@@ -2266,7 +1919,7 @@
},
"internationaldomaindetected": "检测到国际域",
"willbestoredas": "储存为:",
"roleMappingDescription": "确定当用户使用此身份提供者登陆时如何分配角色。",
"roleMappingDescription": "确定当用户启用自动配送时如何分配他们的角色。",
"selectRole": "选择角色",
"roleMappingExpression": "表达式",
"selectRolePlaceholder": "选择角色",
@@ -2276,25 +1929,6 @@
"invalidValue": "无效的值",
"idpTypeLabel": "身份提供者类型",
"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 配置",
"idpGoogleConfigurationDescription": "配置 Google OAuth2 凭据",
"idpGoogleClientIdDescription": "Google OAuth2 Client ID",
@@ -2358,11 +1992,9 @@
"selectDomainForOrgAuthPage": "选择组织认证页面的域",
"domainPickerProvidedDomain": "提供的域",
"domainPickerFreeProvidedDomain": "免费提供的域",
"domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中-无需自带域名。",
"domainPickerVerified": "已验证",
"domainPickerUnverified": "未验证",
"domainPickerManual": "手动",
"domainPickerInvalidSubdomainStructure": "保存时将清除无效字符。",
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
"domainPickerError": "错误",
"domainPickerErrorLoadDomains": "加载组织域名失败",
"domainPickerErrorCheckAvailability": "检查域可用性失败",
@@ -2375,7 +2007,7 @@
"orgAuthChooseIdpDescription": "选择您的身份提供商以继续",
"orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。",
"orgAuthSignInWithPangolin": "使用 Pangolin 登录",
"orgAuthSignInToOrg": "组织身份提供商 (SSO)",
"orgAuthSignInToOrg": "登录到组织",
"orgAuthSelectOrgTitle": "组织登录",
"orgAuthSelectOrgDescription": "输入您的组织ID以继续",
"orgAuthOrgIdPlaceholder": "您的组织",
@@ -2536,7 +2168,7 @@
"alerts": {
"commercialUseDisclosure": {
"title": "使用情况披露",
"description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境--需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。"
"description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境——需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。"
},
"trialPeriodInformation": {
"title": "试用期信息",
@@ -2591,10 +2223,10 @@
},
"scale": {
"title": "缩放比例",
"description": "企业功能,50个用户100个站点,以及优先支持。"
"description": "企业特征、50个用户、50个站点优先支持。"
}
},
"personalUseOnly": "仅个人使用免费许可 - 无需结账)",
"personalUseOnly": "仅个人使用 (免费许可证-无签出)",
"buttons": {
"continueToCheckout": "继续签出"
},
@@ -2668,9 +2300,6 @@
"validPassword": "有效密码",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "查看",
"configManaged": "配置已管理",
"connectedClient": "已连接客户端",
"resourceBlocked": "资源被阻止",
"droppedByRule": "被规则删除",
"noSessions": "无会话",
@@ -2690,14 +2319,12 @@
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
"requestLogsDescription": "查看此机构资源的详细请求日志",
"requestAnalyticsDescription": "查看此机构资源的详细请求分析",
"logRetentionRequestLabel": "HTTP 请求日志保留",
"logRetentionRequestLabel": "请求日志保留",
"logRetentionRequestDescription": "保留请求日志的时间",
"logRetentionAccessLabel": "访问日志保留",
"logRetentionAccessDescription": "保留访问日志的时间",
"logRetentionActionLabel": "动作日志保留",
"logRetentionActionDescription": "保留操作日志的时间",
"logRetentionConnectionLabel": "连接日志保留",
"logRetentionConnectionDescription": "保留连接日志的时间",
"logRetentionDisabled": "已禁用",
"logRetention3Days": "3 天",
"logRetention7Days": "7 天",
@@ -2708,15 +2335,8 @@
"logRetentionEndOfFollowingYear": "下一年结束",
"actionLogsDescription": "查看此机构执行的操作历史",
"accessLogsDescription": "查看此机构资源的访问认证请求",
"connectionLogs": "连接日志",
"connectionLogsDescription": "查看此机构隧道的连接日志",
"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>。",
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
"certResolver": "证书解决器",
"certResolverDescription": "选择用于此资源的证书解析器。",
"selectCertResolver": "选择证书解析",
@@ -2858,9 +2478,6 @@
"machineClients": "机器客户端",
"install": "安装",
"run": "运行",
"envFile": "环境文件",
"serviceFile": "服务文件",
"enableAndStart": "启用并启动",
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
"clientAddress": "客户端地址 (高级)",
"setupFailedToFetchSubnet": "获取默认子网失败",
@@ -2909,13 +2526,7 @@
"editInternalResourceDialogAddClients": "添加客户端",
"editInternalResourceDialogDestinationLabel": "目标",
"editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。",
"internalResourceFormMultiSiteRoutingHelp": "选择多个站点可以实现高可用性的弹性路由和故障转移。",
"internalResourceFormMultiSiteRoutingHelpLearnMore": "了解更多",
"editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。",
"createInternalResourceDialogHttpConfiguration": "HTTP 配置",
"createInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。",
"editInternalResourceDialogHttpConfiguration": "HTTP 配置",
"editInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。",
"editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP",
"editInternalResourceDialogIcmp": "ICMP",
@@ -2954,9 +2565,6 @@
"maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。",
"maintenancePageMessageDescription": "详细说明维护的消息",
"maintenancePageTimeTitle": "预计完成时间(可选)",
"privateMaintenanceScreenTitle": "私有占位符界面",
"privateMaintenanceScreenMessage": "此域名正在私有资源上使用。请连接 Pangolin 客户端以访问此资源。",
"privateMaintenanceScreenSteps": "连接后,如果您仍然看到此消息,说明您的浏览器的DNS缓存可能仍指向旧地址。解决方法:完全关闭并重新打开此标签页或浏览器,然后返回此页面。",
"maintenanceTime": "例如,2小时,11月1日下午5:00",
"maintenanceEstimatedTimeDescription": "您期望维护完成的时间",
"editDomain": "编辑域名",
@@ -3065,236 +2673,5 @@
"approvalsEmptyStateStep2Title": "启用设备批准",
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
"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": "下一页"
"approvalsEmptyStateButtonText": "管理角色"
}
+2392 -2397
View File
File diff suppressed because it is too large Load Diff
+3909 -1874
View File
File diff suppressed because it is too large Load Diff
+41 -45
View File
@@ -32,8 +32,8 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@asteasolutions/zod-to-openapi": "8.4.0",
"@aws-sdk/client-s3": "3.989.0",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
@@ -59,16 +59,16 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/components": "1.0.8",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@react-email/tailwind": "2.0.5",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0",
"@react-email/tailwind": "2.0.4",
"@simplewebauthn/browser": "13.2.2",
"@simplewebauthn/server": "13.2.2",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"arctic": "3.7.0",
"axios": "1.15.0",
"axios": "1.13.5",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -78,63 +78,63 @@
"cors": "2.8.6",
"crypto-js": "4.2.0",
"d3": "7.9.0",
"drizzle-orm": "0.45.2",
"drizzle-orm": "0.45.1",
"express": "5.2.1",
"express-rate-limit": "8.3.0",
"glob": "13.0.6",
"express-rate-limit": "8.2.1",
"glob": "13.0.3",
"helmet": "8.1.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.10.0",
"ioredis": "5.9.3",
"jmespath": "0.16.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "0.577.0",
"lucide-react": "0.563.0",
"maxmind": "5.0.5",
"moment": "2.30.1",
"next": "15.5.15",
"next-intl": "4.8.3",
"next": "15.5.12",
"next-intl": "4.8.2",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "8.0.5",
"nodemailer": "8.0.1",
"oslo": "1.2.1",
"pg": "8.20.0",
"posthog-node": "5.28.0",
"pg": "8.18.0",
"posthog-node": "5.24.15",
"qrcode.react": "4.2.0",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.71.2",
"react-icons": "5.6.0",
"react-hook-form": "7.71.1",
"react-icons": "5.5.0",
"recharts": "2.15.4",
"reodotdev": "1.1.0",
"reodotdev": "1.0.0",
"resend": "6.9.2",
"semver": "7.7.4",
"sshpk": "1.18.0",
"stripe": "20.4.1",
"sshpk": "^1.18.0",
"stripe": "20.3.1",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0",
"tailwind-merge": "3.4.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "10.1.0",
"use-debounce": "^10.1.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.19.0",
"yaml": "2.8.3",
"yaml": "2.8.2",
"yargs": "18.0.0",
"zod": "4.3.6",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.54.1",
"@dotenvx/dotenvx": "1.52.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.2",
"@react-email/preview-server": "5.2.8",
"@tailwindcss/postcss": "4.1.18",
"@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
@@ -146,35 +146,31 @@
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.3.5",
"@types/nodemailer": "7.0.11",
"@types/node": "25.2.3",
"@types/nodemailer": "7.0.9",
"@types/nprogress": "0.2.3",
"@types/pg": "8.18.0",
"@types/pg": "8.16.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/sshpk": "1.17.4",
"@types/sshpk": "^1.17.4",
"@types/swagger-ui-express": "4.1.8",
"@types/topojson-client": "3.1.5",
"@types/ws": "8.18.1",
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.27.4",
"drizzle-kit": "0.31.9",
"esbuild": "0.27.3",
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"postcss": "8.5.8",
"eslint": "9.39.2",
"eslint-config-next": "16.1.6",
"postcss": "8.5.6",
"prettier": "3.8.1",
"react-email": "5.2.10",
"tailwindcss": "4.2.2",
"react-email": "5.2.8",
"tailwindcss": "4.1.18",
"tsc-alias": "1.8.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1"
},
"overrides": {
"esbuild": "0.27.4",
"dompurify": "3.3.2"
"typescript-eslint": "8.55.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

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