diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..c5f140320 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @oschwartz10612 @miloschwartz diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index fff21995d..54ae22194 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -266,7 +266,7 @@ jobs: - name: Install Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: 1.24 + go-version: 1.25 - name: Update version in package.json run: | @@ -415,7 +415,7 @@ jobs: - name: Install cosign # cosign is used to sign and verify container images (key and keyless) - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Dual-sign and verify (GHCR & Docker Hub) # Sign each image by digest using keyless (OIDC) and key-based signing, diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index d6dfdb8fb..f60922d21 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -23,7 +23,7 @@ jobs: skopeo --version - name: Install cosign - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Input check run: | diff --git a/README.md b/README.md index bac7b7e56..f11196f77 100644 --- a/README.md +++ b/README.md @@ -35,43 +35,53 @@ -
-
-
-
-
Get started with Pangolin at app.pangolin.net
-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. +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. ## Installation -- 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. +- 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. -
+
## Deployment Options
-| 



+
+### 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.
+
+
+
+### 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.
+
+
+
+### 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.
+
+
## Download Clients
@@ -87,7 +97,7 @@ Download the Pangolin client for your platform:
### Sign up now
-Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available.
+Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud.
### Check out the docs
@@ -102,7 +112,3 @@ 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.
diff --git a/SECURITY.md b/SECURITY.md
index 6b7372c24..02fbe77d7 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
+2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) with the following information:
- Description and location of the vulnerability.
- Potential impact of the vulnerability.
diff --git a/config/db/.gitignore b/config/db/.gitignore
new file mode 100644
index 000000000..9d4b1bb9c
--- /dev/null
+++ b/config/db/.gitignore
@@ -0,0 +1 @@
+*-journal
diff --git a/install/config.go b/install/config.go
index 548e2ab33..d03415e30 100644
--- a/install/config.go
+++ b/install/config.go
@@ -99,11 +99,6 @@ 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)
@@ -187,7 +182,7 @@ func backupConfig() error {
return nil
}
-func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
+func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
buffer := new(bytes.Buffer)
encoder := yaml.NewEncoder(buffer)
encoder.SetIndent(indent)
@@ -196,7 +191,12 @@ func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) {
return nil, err
}
- defer encoder.Close()
+ defer func() {
+ if cerr := encoder.Close(); cerr != nil && err == nil {
+ err = cerr
+ }
+ }()
+
return buffer.Bytes(), nil
}
diff --git a/install/config/crowdsec/traefik_config.yml b/install/config/crowdsec/traefik_config.yml
index 198693ef8..c1145934d 100644
--- a/install/config/crowdsec/traefik_config.yml
+++ b/install/config/crowdsec/traefik_config.yml
@@ -81,11 +81,19 @@ entryPoints:
transport:
respondingTimeouts:
readTimeout: "30m"
+ http3:
+ advertisedPort: 443
http:
tls:
certResolver: "letsencrypt"
- middlewares:
+ middlewares:
- crowdsec@file
+ encodedCharacters:
+ allowEncodedSlash: true
+ allowEncodedQuestionMark: true
serversTransport:
- insecureSkipVerify: true
\ No newline at end of file
+ insecureSkipVerify: true
+
+ping:
+ entryPoint: "web"
diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml
index c0206e5bf..505089bed 100644
--- a/install/config/docker-compose.yml
+++ b/install/config/docker-compose.yml
@@ -38,6 +38,7 @@ services:
- 51820:51820/udp
- 21820:21820/udp
- 443:443
+ - 443:443/udp # For http3 QUIC if desired
- 80:80
{{end}}
traefik:
diff --git a/install/config/traefik/traefik_config.yml b/install/config/traefik/traefik_config.yml
index 0709b4611..45f5ebb07 100644
--- a/install/config/traefik/traefik_config.yml
+++ b/install/config/traefik/traefik_config.yml
@@ -40,6 +40,8 @@ entryPoints:
transport:
respondingTimeouts:
readTimeout: "30m"
+ http3:
+ advertisedPort: 443
http:
tls:
certResolver: "letsencrypt"
diff --git a/install/go.mod b/install/go.mod
index da73eec0f..005a079df 100644
--- a/install/go.mod
+++ b/install/go.mod
@@ -3,7 +3,7 @@ module installer
go 1.25.0
require (
- github.com/charmbracelet/huh v0.8.0
+ github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
diff --git a/install/go.sum b/install/go.sum
index e0b2a6c5e..b67ae57e5 100644
--- a/install/go.sum
+++ b/install/go.sum
@@ -14,8 +14,8 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs
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 v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
-github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+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=
diff --git a/install/input.go b/install/input.go
index 8b444ecb9..93739e0d0 100644
--- a/install/input.go
+++ b/install/input.go
@@ -85,33 +85,6 @@ func readString(prompt string, defaultValue string) string {
return value
}
-func readStringNoDefault(prompt string) string {
- var value string
-
- for {
- input := huh.NewInput().
- Title(prompt).
- Value(&value).
- Validate(func(s string) error {
- if s == "" {
- return fmt.Errorf("this field is required")
- }
- return nil
- })
-
- err := runField(input)
- handleAbort(err)
-
- if value != "" {
- // Print the answer so it remains visible in terminal history
- if !isAccessibleMode() {
- fmt.Printf("%s: %s\n", prompt, value)
- }
- return value
- }
- }
-}
-
func readPassword(prompt string) string {
var value string
diff --git a/install/main.go b/install/main.go
index 9de332b60..a38d78fc6 100644
--- a/install/main.go
+++ b/install/main.go
@@ -8,12 +8,12 @@ import (
"io"
"io/fs"
"net"
- "net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
+ "strconv"
"strings"
"text/template"
"time"
@@ -90,6 +90,13 @@ func main() {
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()
@@ -287,6 +294,117 @@ 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")
@@ -430,9 +548,9 @@ func createConfigFiles(config Config) error {
}
// Walk through all embedded files
- err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
+ err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
+ if walkErr != nil {
+ return walkErr
}
// Skip the root fs directory itself
@@ -483,7 +601,11 @@ func createConfigFiles(config Config) error {
if err != nil {
return fmt.Errorf("failed to create %s: %v", path, err)
}
- defer outFile.Close()
+ defer func() {
+ if cerr := outFile.Close(); cerr != nil && err == nil {
+ err = cerr
+ }
+ }()
// Execute template
if err := tmpl.Execute(outFile, config); err != nil {
@@ -499,18 +621,26 @@ func createConfigFiles(config Config) error {
return nil
}
-func copyFile(src, dst string) error {
+func copyFile(src, dst string) (err error) {
source, err := os.Open(src)
if err != nil {
return err
}
- defer source.Close()
+ defer func() {
+ if cerr := source.Close(); cerr != nil && err == nil {
+ err = cerr
+ }
+ }()
destination, err := os.Create(dst)
if err != nil {
return err
}
- defer destination.Close()
+ defer func() {
+ if cerr := destination.Close(); cerr != nil && err == nil {
+ err = cerr
+ }
+ }()
_, err = io.Copy(destination, source)
return err
@@ -622,32 +752,6 @@ 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...)
diff --git a/license.py b/license_header_checker.py
similarity index 100%
rename from license.py
rename to license_header_checker.py
diff --git a/messages/bg-BG.json b/messages/bg-BG.json
index 10b83f38d..10204713a 100644
--- a/messages/bg-BG.json
+++ b/messages/bg-BG.json
@@ -148,6 +148,11 @@
"createLink": "Създаване на връзка",
"resourcesNotFound": "Не са намерени ресурси",
"resourceSearch": "Търсене на ресурси",
+ "machineSearch": "Търсене на машини",
+ "machinesSearch": "Търсене на клиенти на машини...",
+ "machineNotFound": "Не са намерени машини",
+ "userDeviceSearch": "Търсене на устройства на потребителя",
+ "userDevicesSearch": "Търсене на устройства на потребителя...",
"openMenu": "Отваряне на менюто",
"resource": "Ресурс",
"title": "Заглавие",
@@ -323,6 +328,54 @@
"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": "Преглед и управление на всички потребители в системата",
@@ -352,6 +405,10 @@
"licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ",
"licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.",
"licenseAbout": "Относно лицензите",
+ "licenseBannerTitle": "Активирайте своята корпоративна лицензия",
+ "licenseBannerDescription": "Отключете корпоративните функции за вашият хостинг на Pangolin. Закупете лицензионен ключ, за да активирате премиум възможности, след това го добавете по-долу.",
+ "licenseBannerGetLicense": "Вземете лиценз",
+ "licenseBannerViewDocs": "Преглед на документацията",
"communityEdition": "Комюнити издание",
"licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.",
"licenseKeyActivated": "Лицензионният ключ е активиран",
@@ -509,9 +566,12 @@
"userSaved": "Потребителят е запазен",
"userSavedDescription": "Потребителят беше актуализиран.",
"autoProvisioned": "Автоматично предоставено",
+ "autoProvisionSettings": "Настройки за автоматично осигуряване",
"autoProvisionedDescription": "Позволете този потребител да бъде автоматично управляван от доставчик на идентификационни данни",
"accessControlsDescription": "Управлявайте какво може да достъпва и прави този потребител в организацията",
"accessControlsSubmit": "Запазване на контролите за достъп",
+ "singleRolePerUserPlanNotice": "Вашият план поддържа само една роля на потребител.",
+ "singleRolePerUserEditionNotice": "Това издание поддържа само една роля на потребител.",
"roles": "Роли",
"accessUsersRoles": "Управление на потребители и роли",
"accessUsersRolesDescription": "Поканете потребители и ги добавете към роли, за да управлявате достъпа до организацията",
@@ -568,6 +628,8 @@
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
"targetErrorNoSite": "Няма избран сайт",
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
+ "targetTargetsCleared": "Мишените са премахнати",
+ "targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс",
"targetCreated": "Целта е създадена",
"targetCreatedDescription": "Целта беше успешно създадена",
"targetErrorCreate": "Неуспешно създаване на целта",
@@ -1119,6 +1181,7 @@
"setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.",
"setupTokenRequired": "Необходим е конфигурационен токен",
"actionUpdateSite": "Актуализиране на сайт",
+ "actionResetSiteBandwidth": "Нулиране на честотната лента на организацията",
"actionListSiteRoles": "Изброяване на позволените роли за сайта",
"actionCreateResource": "Създаване на ресурс",
"actionDeleteResource": "Изтриване на ресурс",
@@ -1148,7 +1211,7 @@
"actionRemoveUser": "Изтрийте потребител",
"actionListUsers": "Изброяване на потребители",
"actionAddUserRole": "Добавяне на роля на потребител",
- "actionSetUserOrgRoles": "Set User Roles",
+ "actionSetUserOrgRoles": "Задайте роли на потребители",
"actionGenerateAccessToken": "Генериране на токен за достъп",
"actionDeleteAccessToken": "Изтриване на токен за достъп",
"actionListAccessTokens": "Изброяване на токени за достъп",
@@ -1265,6 +1328,7 @@
"sidebarRoles": "Роли",
"sidebarShareableLinks": "Връзки",
"sidebarApiKeys": "API ключове",
+ "sidebarProvisioning": "Осигуряване",
"sidebarSettings": "Настройки",
"sidebarAllUsers": "Всички потребители",
"sidebarIdentityProviders": "Идентификационни доставчици",
@@ -1890,6 +1954,40 @@
"exitNode": "Изходен възел",
"country": "Държава",
"rulesMatchCountry": "Понастоящем на базата на изходния IP",
+ "region": "Регион",
+ "selectRegion": "Изберете регион",
+ "searchRegions": "Търсене на региони...",
+ "noRegionFound": "Регионът не е намерен.",
+ "rulesMatchRegion": "Изберете регионална групировка на държави",
+ "rulesErrorInvalidRegion": "Невалиден регион",
+ "rulesErrorInvalidRegionDescription": "Моля, изберете валиден регион.",
+ "regionAfrica": "Африка",
+ "regionNorthernAfrica": "Северна Африка",
+ "regionEasternAfrica": "Източна Африка",
+ "regionMiddleAfrica": "Централна Африка",
+ "regionSouthernAfrica": "Южна Африка",
+ "regionWesternAfrica": "Западна Африка",
+ "regionAmericas": "Америките",
+ "regionCaribbean": "Карибите",
+ "regionCentralAmerica": "Централна Америка",
+ "regionSouthAmerica": "Южна Америка",
+ "regionNorthernAmerica": "Северна Америка",
+ "regionAsia": "Азия",
+ "regionCentralAsia": "Централна Азия",
+ "regionEasternAsia": "Източна Азия",
+ "regionSouthEasternAsia": "Югоизточна Азия",
+ "regionSouthernAsia": "Южна Азия",
+ "regionWesternAsia": "Западна Азия",
+ "regionEurope": "Европа",
+ "regionEasternEurope": "Източна Европа",
+ "regionNorthernEurope": "Северна Европа",
+ "regionSouthernEurope": "Южна Европа",
+ "regionWesternEurope": "Западна Европа",
+ "regionOceania": "Океания",
+ "regionAustraliaAndNewZealand": "Австралия и Нова Зеландия",
+ "regionMelanesia": "Меланезия",
+ "regionMicronesia": "Микронезия",
+ "regionPolynesia": "Полинезия",
"managedSelfHosted": {
"title": "Управлявано Самостоятелно-хоствано",
"description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри",
@@ -1938,6 +2036,25 @@
"invalidValue": "Невалидна стойност",
"idpTypeLabel": "Тип на доставчика на идентичност",
"roleMappingExpressionPlaceholder": "напр.: contains(groups, 'admin') && 'Admin' || 'Member'",
+ "roleMappingModeFixedRoles": "Фиксирани роли",
+ "roleMappingModeMappingBuilder": "Строител на карти",
+ "roleMappingModeRawExpression": "Необработено израз",
+ "roleMappingFixedRolesPlaceholderSelect": "Изберете една или повече роли",
+ "roleMappingFixedRolesPlaceholderFreeform": "Въведете имена на роли (точно съвпадение на организацията)",
+ "roleMappingFixedRolesDescriptionSameForAll": "Присвойте същият набор от роли на всеки автоматично осигурен потребител.",
+ "roleMappingFixedRolesDescriptionDefaultPolicy": "За стандартните политики въведете имена на роли, които съществуват във всяка организация, където е осигурен потребител. Имената трябва да съвпадат точно.",
+ "roleMappingClaimPath": "Път на иск",
+ "roleMappingClaimPathPlaceholder": "групи",
+ "roleMappingClaimPathDescription": "Път в съдържанието на маркера, който съдържа изходни стойности (например групи).",
+ "roleMappingMatchValue": "Съвпадение на стойност",
+ "roleMappingAssignRoles": "Присвояване на роли",
+ "roleMappingAddMappingRule": "Добавяне на правило за картироване",
+ "roleMappingRawExpressionResultDescription": "Изразът трябва да бъде оценен на низ или масив от низове.",
+ "roleMappingRawExpressionResultDescriptionSingleRole": "Изразът трябва да бъде оценен на низ (едно име на роля).",
+ "roleMappingMatchValuePlaceholder": "Съвпадение на стойност (например: администратор)",
+ "roleMappingAssignRolesPlaceholderFreeform": "Въведете имена на роли (точно по организация)",
+ "roleMappingBuilderFreeformRowHint": "Имената на ролите трябва да съвпадат с роля във всяка целева организация.",
+ "roleMappingRemoveRule": "Премахни",
"idpGoogleConfiguration": "Конфигурация на Google",
"idpGoogleConfigurationDescription": "Конфигурирайте Google OAuth2 идентификационни данни",
"idpGoogleClientIdDescription": "Google OAuth2 идентификационен клиент",
@@ -2001,8 +2118,10 @@
"selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията",
"domainPickerProvidedDomain": "Предоставен домейн",
"domainPickerFreeProvidedDomain": "Безплатен предоставен домейн",
+ "domainPickerFreeDomainsPaidFeature": "Предоставените домейни са платена функция. Абонирайте се, за да получите домейн, включен във вашия план - няма нужда да използвате вашия собствен.",
"domainPickerVerified": "Проверено",
"domainPickerUnverified": "Непроверено",
+ "domainPickerManual": "Ръчно",
"domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.",
"domainPickerError": "Грешка",
"domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията",
@@ -2235,7 +2354,7 @@
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
}
},
- "personalUseOnly": "Само за лична употреба (безплатен лиценз — без плащане)",
+ "personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",
"buttons": {
"continueToCheckout": "Продължете към плащане"
},
@@ -2334,6 +2453,8 @@
"logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп",
"logRetentionActionLabel": "Задържане на логове за действия",
"logRetentionActionDescription": "Колко дълго да се задържат логовете за действия",
+ "logRetentionConnectionLabel": "Запазване на дневниците на връзките",
+ "logRetentionConnectionDescription": "Колко дълго да се съхраняват дневниците на връзките",
"logRetentionDisabled": "Деактивирано",
"logRetention3Days": "3 дни",
"logRetention7Days": "7 дни",
@@ -2344,6 +2465,13 @@
"logRetentionEndOfFollowingYear": "Край на следващата година",
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
+ "connectionLogs": "Логове на връзката",
+ "connectionLogsDescription": "Вижте логовете на връзките за тунелите в тази организация",
+ "sidebarLogsConnection": "Логове на връзката",
+ "sidebarLogsStreaming": "Потоци",
+ "sourceAddress": "Източен адрес",
+ "destinationAddress": "Адрес на дестинация",
+ "duration": "Продължителност",
"licenseRequiredToUse": "Изисква се лиценз за + {t("httpDestNoHeadersConfigured")} +
+ )} + {headers.map((h, i) => ( ++ {urlError} +
+ )} ++ {t("httpDestAuthDescription")} +
++ {t("httpDestAuthNoneDescription")} +
++ {t("httpDestAuthBearerDescription")} +
++ {t("httpDestAuthBasicDescription")} +
++ {t("httpDestAuthCustomDescription")} +
++ {t("httpDestCustomHeadersDescription")} +
++ {t("httpDestBodyTemplateDescription")} +
++ {t("httpDestPayloadFormatDescription")} +
++ {t("httpDestFormatJsonArrayDescription")} +
++ {t("httpDestFormatNdjsonDescription")} +
++ {t("httpDestFormatSingleDescription")} +
++ {t("httpDestLogTypesDescription")} +
++ {t("httpDestAccessLogsDescription")} +
++ {t("httpDestActionLogsDescription")} +
++ {t("httpDestConnectionLogsDescription")} +
++ {t("httpDestRequestLogsDescription")} +
+- This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite. + This organization has reached its user limit. Please + contact the organization administrator to upgrade their + plan before accepting this invite.
); diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index 201aeb18a..e647f7dd1 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -12,6 +12,8 @@ import clsx from "clsx"; import { useTransition } from "react"; import { Locale } from "@/i18n/config"; import { setUserLocale } from "@/services/locale"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type Props = { defaultValue: string; @@ -25,12 +27,17 @@ export default function LocaleSwitcherSelect({ label }: Props) { const [isPending, startTransition] = useTransition(); + const api = createApiClient(useEnvContext()); function onChange(value: string) { const locale = value as Locale; startTransition(() => { setUserLocale(locale); }); + // Persist locale to the database (fire-and-forget) + api.post("/user/locale", { locale }).catch(() => { + // Silently ignore errors — cookie is already set as fallback + }); } const selected = items.find((item) => item.value === defaultValue); diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx new file mode 100644 index 000000000..c65cb218e --- /dev/null +++ b/src/components/PendingSitesTable.tsx @@ -0,0 +1,473 @@ +"use client"; + +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { build } from "@server/build"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { type PaginationState } from "@tanstack/react-table"; +import { + ArrowDown01Icon, + ArrowUp10Icon, + ArrowUpRight, + Check, + ChevronsUpDownIcon, + MoreHorizontal +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import z from "zod"; +import { ColumnFilterButton } from "./ColumnFilterButton"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "./ui/controlled-data-table"; +import { SiteRow } from "./SitesTable"; + +type PendingSitesTableProps = { + sites: SiteRow[]; + pagination: PaginationState; + orgId: string; + rowCount: number; +}; + +export default function PendingSitesTable({ + sites, + orgId, + pagination, + rowCount +}: PendingSitesTableProps) { + const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + + const [isRefreshing, startTransition] = useTransition(); + const [approvingIds, setApprovingIds] = useState