From ffbea7af59eb549daa5cb3be8f031916e5f52861 Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Tue, 17 Feb 2026 08:53:40 +0300 Subject: [PATCH 01/33] fix(install): add error handling and minor code cleanups Signed-off-by: Rodney Osodo --- install/config.go | 5 ++-- install/containers.go | 13 ++++----- install/crowdsec.go | 15 ++++++++--- install/input.go | 14 +++++----- install/main.go | 61 +++++++++++++++++++++++++++---------------- 5 files changed, 67 insertions(+), 41 deletions(-) diff --git a/install/config.go b/install/config.go index e75dd50dd..37563b936 100644 --- a/install/config.go +++ b/install/config.go @@ -192,8 +192,7 @@ func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) { encoder := yaml.NewEncoder(buffer) encoder.SetIndent(indent) - err := encoder.Encode(data) - if err != nil { + if err := encoder.Encode(data); err != nil { return nil, err } @@ -209,7 +208,7 @@ func replaceInFile(filepath, oldStr, newStr string) error { } // Replace the string - newContent := strings.Replace(string(content), oldStr, newStr, -1) + newContent := strings.ReplaceAll(string(content), oldStr, newStr) // Write the modified content back to the file err = os.WriteFile(filepath, []byte(newContent), 0644) diff --git a/install/containers.go b/install/containers.go index 333fd890c..b5d18423b 100644 --- a/install/containers.go +++ b/install/containers.go @@ -144,12 +144,13 @@ func installDocker() error { } func startDockerService() error { - if runtime.GOOS == "linux" { + switch runtime.GOOS { + case "linux": cmd := exec.Command("systemctl", "enable", "--now", "docker") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() - } else if runtime.GOOS == "darwin" { + case "darwin": // On macOS, Docker is usually started via the Docker Desktop application fmt.Println("Please start Docker Desktop manually on macOS.") return nil @@ -302,7 +303,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. @@ -325,7 +326,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. @@ -347,7 +348,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. @@ -369,5 +370,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) } diff --git a/install/crowdsec.go b/install/crowdsec.go index 401ef215c..50ff27fec 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -27,9 +27,18 @@ func installCrowdsec(config Config) error { os.Exit(1) } - os.MkdirAll("config/crowdsec/db", 0755) - os.MkdirAll("config/crowdsec/acquis.d", 0755) - os.MkdirAll("config/traefik/logs", 0755) + 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) + } if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) diff --git a/install/input.go b/install/input.go index db70b4c00..9bde20f36 100644 --- a/install/input.go +++ b/install/input.go @@ -57,11 +57,12 @@ func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { for { input := readString(reader, prompt+" (yes/no)", defaultStr) lower := strings.ToLower(input) - if lower == "yes" { + switch lower { + case "yes": return true - } else if lower == "no" { + case "no": return false - } else { + default: fmt.Println("Please enter 'yes' or 'no'.") } } @@ -71,11 +72,12 @@ func readBoolNoDefault(reader *bufio.Reader, prompt string) bool { for { input := readStringNoDefault(reader, prompt+" (yes/no)") lower := strings.ToLower(input) - if lower == "yes" { + switch lower { + case "yes": return true - } else if lower == "no" { + case "no": return false - } else { + default: fmt.Println("Please enter 'yes' or 'no'.") } } diff --git a/install/main.go b/install/main.go index 242af7416..db480fbb8 100644 --- a/install/main.go +++ b/install/main.go @@ -2,12 +2,12 @@ package main import ( "bufio" + "crypto/rand" "embed" + "encoding/base64" "fmt" "io" "io/fs" - "crypto/rand" - "encoding/base64" "net" "net/http" "net/url" @@ -102,7 +102,10 @@ func main() { os.Exit(1) } - moveFile("config/docker-compose.yml", "docker-compose.yml") + 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) + } fmt.Println("\nConfiguration files created successfully!") @@ -123,7 +126,11 @@ func main() { if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker { if readBool(reader, "Docker is not installed. Would you like to install it?", true) { - installDocker() + if err := installDocker(); err != nil { + fmt.Printf("Error installing Docker: %v\n", err) + return + } + // try to start docker service but ignore errors if err := startDockerService(); err != nil { fmt.Println("Error starting Docker service:", err) @@ -132,7 +139,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 i := 0; i < 5; i++ { + for range 5 { if isDockerRunning() { fmt.Println("Docker is running!") break @@ -290,7 +297,8 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { os.Exit(1) } - if chosenContainer == Podman { + switch chosenContainer { + case 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) @@ -311,7 +319,7 @@ func podmanOrDocker(reader *bufio.Reader) 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 { @@ -321,7 +329,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { fmt.Println("Unprivileged ports have been configured.") } - } else if chosenContainer == Docker { + case Docker: // check if docker is not installed and the user is root if !isDockerInstalled() { if os.Geteuid() != 0 { @@ -336,7 +344,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { fmt.Println("The installer will not be able to run docker commands without running it as root.") os.Exit(1) } - } else { + default: // This shouldn't happen unless there's a third container runtime. os.Exit(1) } @@ -405,10 +413,18 @@ func collectUserInput(reader *bufio.Reader) Config { } func createConfigFiles(config Config) error { - os.MkdirAll("config", 0755) - os.MkdirAll("config/letsencrypt", 0755) - os.MkdirAll("config/db", 0755) - os.MkdirAll("config/logs", 0755) + 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) + } // Walk through all embedded files err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { @@ -562,22 +578,24 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai fmt.Println("To get your setup token, you need to:") fmt.Println("") fmt.Println("1. Start the containers") - if containerType == Docker { + switch containerType { + case Docker: fmt.Println(" docker compose up -d") - } else if containerType == Podman { + case 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") - if containerType == Docker { + switch containerType { + case Docker: fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") - } else if containerType == Podman { + case 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 ===") @@ -639,10 +657,7 @@ 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\n\n", - port, err, - ) + return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err) } if closeErr := ln.Close(); closeErr != nil { fmt.Fprintf(os.Stderr, From 952d0c74d0b88d8a65a44c5b18aa03c9921a2ca0 Mon Sep 17 00:00:00 2001 From: Rodney Osodo Date: Tue, 17 Feb 2026 08:54:11 +0300 Subject: [PATCH 02/33] refactor(install): use any for YAML map types instead of interface{} Signed-off-by: Rodney Osodo --- install/config.go | 34 +++++++++++++++++----------------- install/crowdsec.go | 14 +++++++------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/install/config.go b/install/config.go index 37563b936..548e2ab33 100644 --- a/install/config.go +++ b/install/config.go @@ -118,19 +118,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error { } // Parse source Docker Compose YAML - var sourceCompose map[string]interface{} + var sourceCompose map[string]any 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]interface{} + var destCompose map[string]any 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]interface{}) + sourceServices, ok := sourceCompose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found in source file or has invalid format") } @@ -142,10 +142,10 @@ func copyDockerService(sourceFile, destFile, serviceName string) error { } // Get or create services section in destination - destServices, ok := destCompose["services"].(map[string]interface{}) + destServices, ok := destCompose["services"].(map[string]any) if !ok { // If services section doesn't exist, create it - destServices = make(map[string]interface{}) + destServices = make(map[string]any) destCompose["services"] = destServices } @@ -187,7 +187,7 @@ func backupConfig() error { return nil } -func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) { +func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) { buffer := new(bytes.Buffer) encoder := yaml.NewEncoder(buffer) encoder.SetIndent(indent) @@ -227,28 +227,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error { } // Parse YAML into a generic map - var compose map[string]interface{} + var compose map[string]any 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]interface{}) + services, ok := compose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found or invalid") } // Get traefik service - traefik, ok := services["traefik"].(map[string]interface{}) + traefik, ok := services["traefik"].(map[string]any) if !ok { return fmt.Errorf("traefik service not found or invalid") } // Check volumes logVolume := "./config/traefik/logs:/var/log/traefik" - var volumes []interface{} + var volumes []any - if existingVolumes, ok := traefik["volumes"].([]interface{}); ok { + if existingVolumes, ok := traefik["volumes"].([]any); ok { // Check if volume already exists for _, v := range existingVolumes { if v.(string) == logVolume { @@ -294,13 +294,13 @@ func MergeYAML(baseFile, overlayFile string) error { } // Parse base YAML into a map - var baseMap map[string]interface{} + var baseMap map[string]any 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]interface{} + var overlayMap map[string]any if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil { return fmt.Errorf("error parsing overlay YAML: %v", err) } @@ -323,8 +323,8 @@ func MergeYAML(baseFile, overlayFile string) error { } // mergeMap recursively merges two maps -func mergeMap(base, overlay map[string]interface{}) map[string]interface{} { - result := make(map[string]interface{}) +func mergeMap(base, overlay map[string]any) map[string]any { + result := make(map[string]any) // Copy all key-values from base map for k, v := range base { @@ -335,8 +335,8 @@ func mergeMap(base, overlay map[string]interface{}) map[string]interface{} { 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]interface{}); isBaseMap { - if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap { + if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap { + if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap { result[k] = mergeMap(baseMap, overlayMap) continue } diff --git a/install/crowdsec.go b/install/crowdsec.go index 50ff27fec..c75dccf32 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -162,34 +162,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error { } // Parse YAML into a generic map - var compose map[string]interface{} + var compose map[string]any 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]interface{}) + services, ok := compose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found or invalid") } // Get traefik service - traefik, ok := services["traefik"].(map[string]interface{}) + traefik, ok := services["traefik"].(map[string]any) if !ok { return fmt.Errorf("traefik service not found or invalid") } // Get dependencies - dependsOn, ok := traefik["depends_on"].(map[string]interface{}) + dependsOn, ok := traefik["depends_on"].(map[string]any) if ok { // Append the new block for crowdsec - dependsOn["crowdsec"] = map[string]interface{}{ + dependsOn["crowdsec"] = map[string]any{ "condition": "service_healthy", } } else { // No dependencies exist, create it - traefik["depends_on"] = map[string]interface{}{ - "crowdsec": map[string]interface{}{ + traefik["depends_on"] = map[string]any{ + "crowdsec": map[string]any{ "condition": "service_healthy", }, } From 9460e28c7bc190465c7697dac2cecee0da3b2a35 Mon Sep 17 00:00:00 2001 From: Laurence Date: Wed, 18 Feb 2026 09:43:41 +0000 Subject: [PATCH 03/33] ehance(installer): use ldflags to inject versions Instead of the CI/CD using sed to replace the 'replaceme' text we can instead use ldflags which can inject variables at build time to the versions. The makefile had a bunch of workarounds for dev so these have been removed to cleanup etc etc and fetchs versions from the gh api directly if the variables are not injected like the CI/CD does --- .github/workflows/cicd.yml | 18 ++++---------- install/Makefile | 51 +++++++++++++------------------------- install/main.go | 18 +++++++++----- 3 files changed, 34 insertions(+), 53 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7358fa2a8..5a776c99c 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -289,22 +289,14 @@ 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 + make go-build-release \ + PANGOLIN_VERSION=${{ env.TAG }} \ + GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \ + BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} + shell: bash - name: Upload artifacts from /install/bin uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/install/Makefile b/install/Makefile index 53365f509..8a836b77e 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,41 +1,24 @@ -all: update-versions go-build-release put-back -dev-all: dev-update-versions dev-build dev-clean +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) go-build-release: - 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 + @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 clean: rm -f bin/installer_linux_amd64 rm -f bin/installer_linux_arm64 -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" +.PHONY: all go-build-release clean diff --git a/install/main.go b/install/main.go index 242af7416..9c589321b 100644 --- a/install/main.go +++ b/install/main.go @@ -2,12 +2,12 @@ package main import ( "bufio" + "crypto/rand" "embed" + "encoding/base64" "fmt" "io" "io/fs" - "crypto/rand" - "encoding/base64" "net" "net/http" "net/url" @@ -20,11 +20,17 @@ import ( "time" ) -// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD +// Version variables injected at build time via -ldflags +var ( + pangolinVersion string + gerbilVersion string + badgerVersion string +) + func loadVersions(config *Config) { - config.PangolinVersion = "replaceme" - config.GerbilVersion = "replaceme" - config.BadgerVersion = "replaceme" + config.PangolinVersion = pangolinVersion + config.GerbilVersion = gerbilVersion + config.BadgerVersion = badgerVersion } //go:embed config/* From e8398cb221da44f84555d66d6fcae6fdfc3dddf6 Mon Sep 17 00:00:00 2001 From: Laurence Date: Wed, 18 Feb 2026 11:05:48 +0000 Subject: [PATCH 04/33] enhance(installer): use huh package to handle input Instead of relying on stdin and stdout by default, using the huh package from charmbracelet allows us to handle user input more gracefully such as y/n instead of typing 'yes' or 'no'. If a user makes a mistake whilst typing in any text fields they cannot use left or right to edit a single character when using huh it can. This adds a dependancy and may increase the size of installer but overall improves user experience. --- install/go.mod | 32 +++++- install/go.sum | 81 ++++++++++++- install/input.go | 287 +++++++++++++++++++++++++++++++++++------------ install/main.go | 63 +++++------ install/theme.go | 51 +++++++++ 5 files changed, 403 insertions(+), 111 deletions(-) create mode 100644 install/theme.go diff --git a/install/go.mod b/install/go.mod index c4e72fc71..604b58e3b 100644 --- a/install/go.mod +++ b/install/go.mod @@ -3,8 +3,36 @@ module installer go 1.24.0 require ( - golang.org/x/term v0.39.0 + github.com/charmbracelet/huh v0.8.0 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.40.0 // indirect +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/lipgloss v1.1.0 // 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.41.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/install/go.sum b/install/go.sum index e3e319c3d..faf7093bc 100644 --- a/install/go.sum +++ b/install/go.sum @@ -1,7 +1,80 @@ -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= +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 v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install/input.go b/install/input.go index db70b4c00..8b444ecb9 100644 --- a/install/input.go +++ b/install/input.go @@ -1,92 +1,235 @@ package main import ( - "bufio" + "errors" "fmt" - "strings" - "syscall" + "os" + "strconv" + "github.com/charmbracelet/huh" "golang.org/x/term" ) -func readString(reader *bufio.Reader, prompt string, defaultValue string) string { +// 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 if defaultValue != "" { - fmt.Printf("%s (default: %s): ", prompt, defaultValue) - } else { - fmt.Print(prompt + ": ") + title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue) } - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - if input == "" { - return defaultValue - } - return input -} -func readStringNoDefault(reader *bufio.Reader, prompt string) string { - fmt.Print(prompt + ": ") - input, _ := reader.ReadString('\n') - return strings.TrimSpace(input) -} + input := huh.NewInput(). + Title(title). + Value(&value) -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, "") + // 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 + }) } -} -func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { - defaultStr := "no" - if defaultValue { - defaultStr = "yes" - } - for { - 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'.") - } - } -} + err := runField(input) + handleAbort(err) -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'.") - } + if value == "" { + value = defaultValue } -} -func readInt(reader *bufio.Reader, prompt string, defaultValue int) int { - input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue)) - if input == "" { - return 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) } - value := defaultValue - fmt.Sscanf(input, "%d", &value) + 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 + + 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 + } + } +} + +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" + } + 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) + } + 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 +} diff --git a/install/main.go b/install/main.go index 242af7416..7ea1a8e1a 100644 --- a/install/main.go +++ b/install/main.go @@ -1,13 +1,12 @@ package main import ( - "bufio" + "crypto/rand" "embed" + "encoding/base64" "fmt" "io" "io/fs" - "crypto/rand" - "encoding/base64" "net" "net/http" "net/url" @@ -82,14 +81,12 @@ func main() { } } - reader := bufio.NewReader(os.Stdin) - var config Config var alreadyInstalled = false // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { - config = collectUserInput(reader) + config = collectUserInput() loadVersions(&config) config.DoCrowdsecInstall = false @@ -117,12 +114,12 @@ func main() { fmt.Println("\n=== Starting installation ===") - if readBool(reader, "Would you like to install and start the containers?", true) { + if readBool("Would you like to install and start the containers?", true) { - config.InstallationContainerType = podmanOrDocker(reader) + config.InstallationContainerType = podmanOrDocker() if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker { - if readBool(reader, "Docker is not installed. Would you like to install it?", true) { + if readBool("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 { @@ -167,7 +164,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(reader, "Would you like to update the MaxMind database to the latest version?", false) { + if readBool("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.") @@ -175,7 +172,7 @@ func main() { } } else { fmt.Println("MaxMind GeoLite2 Country database not found.") - if readBool(reader, "Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) { + if readBool("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.") @@ -192,11 +189,11 @@ func main() { if !checkIsCrowdsecInstalledInCompose() { fmt.Println("\n=== CrowdSec Install ===") // check if crowdsec is installed - if readBool(reader, "Would you like to install CrowdSec?", false) { + if readBool("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(reader, "Are you willing to manage CrowdSec?", false) { + if readBool("Are you willing to manage CrowdSec?", false) { if config.DashboardDomain == "" { traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml") if err != nil { @@ -225,8 +222,8 @@ func main() { fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) fmt.Printf("Badger Version: %s\n", config.BadgerVersion) - if !readBool(reader, "Are these values correct?", true) { - config = collectUserInput(reader) + if !readBool("Are these values correct?", true) { + config = collectUserInput() } } @@ -235,7 +232,7 @@ func main() { if detectedType == Undefined { // If detection fails, prompt the user fmt.Println("Unable to detect container type from existing installation.") - config.InstallationContainerType = podmanOrDocker(reader) + config.InstallationContainerType = podmanOrDocker() } else { config.InstallationContainerType = detectedType fmt.Printf("Detected container type: %s\n", config.InstallationContainerType) @@ -277,8 +274,8 @@ func main() { fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } -func podmanOrDocker(reader *bufio.Reader) SupportedContainer { - inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker") +func podmanOrDocker() SupportedContainer { + inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker") chosenContainer := Docker if strings.EqualFold(inputContainer, "docker") { @@ -299,7 +296,7 @@ func podmanOrDocker(reader *bufio.Reader) 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(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) + 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) if approved { if os.Geteuid() != 0 { fmt.Println("You need to run the installer as root for such a configuration.") @@ -344,35 +341,35 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { return chosenContainer } -func collectUserInput(reader *bufio.Reader) Config { +func collectUserInput() Config { config := Config{} // Basic configuration fmt.Println("\n=== Basic Configuration ===") - 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.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.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") + config.BaseDomain = readString("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(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) + 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) // Email configuration fmt.Println("\n=== Email Configuration ===") - config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false) + config.EnableEmail = readBool("Enable email functionality (SMTP)", false) if config.EnableEmail { - 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)", "") + 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)", "") } // Validate required fields @@ -393,8 +390,8 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Advanced Configuration ===") - 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) + 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) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") diff --git a/install/theme.go b/install/theme.go new file mode 100644 index 000000000..61247cf1a --- /dev/null +++ b/install/theme.go @@ -0,0 +1,51 @@ +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 +} From 848d4d91e6f71e2f92fb0a29adecd2a5fb195a66 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 23 Feb 2026 13:40:08 -0800 Subject: [PATCH 05/33] fix sidebar --- src/components/LayoutMobileMenu.tsx | 26 ++-------- src/components/LayoutSidebar.tsx | 76 ++++++++++++++++------------- 2 files changed, 45 insertions(+), 57 deletions(-) diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index f24c2f130..b661d780a 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -5,13 +5,11 @@ import { SidebarNav } from "@app/components/SidebarNav"; import { OrgSelector } from "@app/components/OrgSelector"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import SupporterStatus from "@app/components/SupporterStatus"; import { Button } from "@app/components/ui/button"; -import { ExternalLink, Menu, Server } from "lucide-react"; +import { ArrowRight, Menu, Server } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; -import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import ProfileIcon from "@app/components/ProfileIcon"; import ThemeSwitcher from "@app/components/ThemeSwitcher"; @@ -44,7 +42,6 @@ export function LayoutMobileMenu({ const pathname = usePathname(); const isAdminPage = pathname?.startsWith("/admin"); const { user } = useUserContext(); - const { env } = useEnvContext(); const t = useTranslations(); return ( @@ -83,7 +80,7 @@ export function LayoutMobileMenu({
{!isAdminPage && user.serverAdmin && ( -
+
- + {t( "serverAdmin" )} +
)} @@ -115,22 +113,6 @@ export function LayoutMobileMenu({
-
- - {env?.app?.version && ( -
- - v{env.app.version} - - -
- )} -
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 940e91fea..e9e2d61eb 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -146,6 +146,46 @@ export function LayoutSidebar({ />
+ {!isAdminPage && user.serverAdmin && ( +
+ + + + + {!isSidebarCollapsed && ( + <> + + {t("serverAdmin")} + + + + )} + +
+ )}
- {!isAdminPage && user.serverAdmin && ( -
- - - - - {!isSidebarCollapsed && ( - <> - - {t("serverAdmin")} - - - - )} - -
- )} - {isSidebarCollapsed && (
@@ -218,7 +224,7 @@ export function LayoutSidebar({
-
+
{canShowProductUpdates && (
From c71f46ede569907356e1b441fe96a2dd35868019 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 24 Feb 2026 19:44:08 -0800 Subject: [PATCH 06/33] move copy button and fix translation --- messages/en-US.json | 3 ++- src/app/navigation.tsx | 2 +- src/components/CopyToClipboard.tsx | 24 ++++++++++++------------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d872d8e39..2dfa496f9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -649,7 +649,8 @@ "resourcesUsersRolesAccess": "User and role-based access control", "resourcesErrorUpdate": "Failed to toggle resource", "resourcesErrorUpdateDescription": "An error occurred while updating the resource", - "access": "Access Control", + "access": "Access", + "accessControl": "Access Control", "shareLink": "{resource} Share Link", "resourceSelect": "Select resource", "shareLinks": "Share Links", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 915e5f04a..0066721db 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -107,7 +107,7 @@ export const orgNavSections = ( ] }, { - heading: "access", + heading: "accessControl", items: [ { title: "sidebarTeam", diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx index b755f9a59..dca14728c 100644 --- a/src/components/CopyToClipboard.tsx +++ b/src/components/CopyToClipboard.tsx @@ -31,6 +31,18 @@ const CopyToClipboard = ({ return (
+ {isLink ? ( )} -
); }; From 8ea6d9fa67af558fbd626e06e0c59a77d40cff76 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 24 Feb 2026 22:04:15 -0800 Subject: [PATCH 07/33] add get user by username search endpoint to integration api --- server/routers/integration.ts | 7 + server/routers/user/getOrgUser.ts | 2 +- server/routers/user/getOrgUserByUsername.ts | 136 ++++++++++++++++++++ server/routers/user/index.ts | 1 + 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 server/routers/user/getOrgUserByUsername.ts diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 6c39fe983..7272d740d 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -689,6 +689,13 @@ authenticated.get( user.getOrgUser ); +authenticated.get( + "/org/:orgId/user-by-username", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrgUser), + user.getOrgUserByUsername +); + authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index f22a29d37..c0a990eeb 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; -async function queryUser(orgId: string, userId: string) { +export async function queryUser(orgId: string, userId: string) { const [user] = await db .select({ orgId: userOrgs.orgId, diff --git a/server/routers/user/getOrgUserByUsername.ts b/server/routers/user/getOrgUserByUsername.ts new file mode 100644 index 000000000..b047fdc06 --- /dev/null +++ b/server/routers/user/getOrgUserByUsername.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { queryUser, type GetOrgUserResponse } from "./getOrgUser"; + +const getOrgUserByUsernameParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const getOrgUserByUsernameQuerySchema = z.strictObject({ + username: z.string().min(1, "username is required"), + idpId: z + .string() + .optional() + .transform((v) => + v === undefined || v === "" ? undefined : parseInt(v, 10) + ) + .refine( + (v) => + v === undefined || (Number.isInteger(v) && (v as number) > 0), + { message: "idpId must be a positive integer" } + ) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user-by-username", + description: + "Get a user in an organization by username. When idpId is not passed, only internal users are searched (username is globally unique for them). For external (OIDC) users, pass idpId to search by username within that identity provider.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: getOrgUserByUsernameParamsSchema, + query: getOrgUserByUsernameQuerySchema + }, + responses: {} +}); + +export async function getOrgUserByUsername( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgUserByUsernameParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = getOrgUserByUsernameQuerySchema.safeParse( + req.query + ); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { username, idpId } = parsedQuery.data; + + const conditions = [ + eq(userOrgs.orgId, orgId), + eq(users.username, username) + ]; + if (idpId !== undefined) { + conditions.push(eq(users.idpId, idpId)); + } else { + conditions.push(eq(users.type, "internal")); + } + + const candidates = await db + .select({ userId: users.userId }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .where(and(...conditions)); + + if (candidates.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with username '${username}' not found in organization` + ) + ); + } + + if (candidates.length > 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Multiple users with this username (external users from different identity providers). Specify idpId (identity provider ID) to disambiguate. When not specified, this searches for internal users only." + ) + ); + } + + const user = await queryUser(orgId, candidates[0].userId); + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with username '${username}' not found in organization` + ) + ); + } + + return response(res, { + data: user, + success: true, + error: false, + message: "User retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 35c5c4a7c..b6fb05d92 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -5,6 +5,7 @@ export * from "./addUserRole"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; +export * from "./getOrgUserByUsername"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; From c64dd14b1a1fcc410c1adb6b02e063ce9d97c9b6 Mon Sep 17 00:00:00 2001 From: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:54:31 +0530 Subject: [PATCH 08/33] fix: correct session DELETE tautology and HTTP cookie domain interpolation --- server/auth/sessions/resource.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 3b9da3d73..a1ae13373 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -87,7 +87,7 @@ export async function validateResourceSessionToken( if (Date.now() >= resourceSession.expiresAt) { await db .delete(resourceSessions) - .where(eq(resourceSessions.sessionId, resourceSessions.sessionId)); + .where(eq(resourceSessions.sessionId, sessionId)); return { resourceSession: null }; } else if ( Date.now() >= @@ -181,7 +181,7 @@ export function serializeResourceSessionCookie( return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`; } else { if (expiresAt === undefined) { - return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=$domain}`; + return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${domain}`; } return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`; } From c600da71e3ffb873ba0ad27fe49363a44bccd9ae Mon Sep 17 00:00:00 2001 From: Abhinav-kodes <183825080+Abhinav-kodes@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:07:08 +0530 Subject: [PATCH 09/33] fix: sync resource toggle states with context on initial load - Replace defaultChecked with checked for controlled components - Add useEffect to sync rulesEnabled, ssoEnabled, whitelistEnabled when resource context hydrates after mount - Add nullish coalescing fallback to prevent undefined initial state --- .../proxy/[niceId]/authentication/page.tsx | 17 +++++++++++++---- .../resources/proxy/[niceId]/rules/page.tsx | 9 +++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index b00ce1eeb..a533fb6c3 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -187,7 +187,11 @@ export default function ResourceAuthenticationPage() { number | null >(null); - const [ssoEnabled, setSsoEnabled] = useState(resource.sso); + const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); + + useEffect(() => { + setSsoEnabled(resource.sso ?? false); + }, [resource.sso]); const [selectedIdpId, setSelectedIdpId] = useState( resource.skipToIdpId || null @@ -472,7 +476,7 @@ export default function ResourceAuthenticationPage() { setSsoEnabled(val)} /> @@ -800,8 +804,13 @@ function OneTimePasswordFormSection({ }: OneTimePasswordFormSectionProps) { const { env } = useEnvContext(); const [whitelistEnabled, setWhitelistEnabled] = useState( - resource.emailWhitelistEnabled + resource.emailWhitelistEnabled ?? false ); + + useEffect(() => { + setWhitelistEnabled(resource.emailWhitelistEnabled); + }, [resource.emailWhitelistEnabled]); + const queryClient = useQueryClient(); const [loadingSaveWhitelist, startTransition] = useTransition(); @@ -894,7 +903,7 @@ function OneTimePasswordFormSection({ diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index f0ac0e1a7..2b6a16870 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -113,7 +113,12 @@ export default function ResourceRules(props: { const [rulesToRemove, setRulesToRemove] = useState([]); const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); - const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); + const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false); + + useEffect(() => { + setRulesEnabled(resource.applyRules); + }, [resource.applyRules]); + const [openCountrySelect, setOpenCountrySelect] = useState(false); const [countrySelectValue, setCountrySelectValue] = useState(""); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = @@ -836,7 +841,7 @@ export default function ResourceRules(props: { setRulesEnabled(val)} />
From 2282d3ae399c1e3b1b069a60eb0657a1cd266eaa Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 10:53:56 -0800 Subject: [PATCH 10/33] Fix formatting --- install/config/docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 1d8b73b4b..e828ea6c6 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -38,9 +38,7 @@ services: 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 From e18c9afc2d628c1f0b99e85968ae76cc4beeb6fd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 25 Feb 2026 11:24:24 -0800 Subject: [PATCH 11/33] add sqlite migration --- server/setup/migrationsSqlite.ts | 6 +- server/setup/scriptsSqlite/1.16.0.ts | 157 +++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 17bc7f199..da7e6b6d1 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -7,6 +7,7 @@ import { versionMigrations } from "../db/sqlite"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { SqliteError } from "better-sqlite3"; import fs from "fs"; +import { build } from "@server/build"; import m1 from "./scriptsSqlite/1.0.0-beta1"; import m2 from "./scriptsSqlite/1.0.0-beta2"; import m3 from "./scriptsSqlite/1.0.0-beta3"; @@ -37,7 +38,7 @@ import m32 from "./scriptsSqlite/1.14.0"; import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; -import { build } from "@server/build"; +import m36 from "./scriptsSqlite/1.16.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -73,7 +74,8 @@ const migrations = [ { version: "1.14.0", run: m32 }, { version: "1.15.0", run: m33 }, { version: "1.15.3", run: m34 }, - { version: "1.15.4", run: m35 } + { version: "1.15.4", run: m35 }, + { version: "1.16.0", run: m36 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts index 459367339..cf128e481 100644 --- a/server/setup/scriptsSqlite/1.16.0.ts +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -1,23 +1,164 @@ -import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { encrypt } from "@server/lib/crypto"; +import { generateCA } from "@server/private/lib/sshCA"; import Database from "better-sqlite3"; +import fs from "fs"; import path from "path"; +import yaml from "js-yaml"; const version = "1.16.0"; +function getServerSecret(): string { + const envSecret = process.env.SERVER_SECRET; + + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + // If no config file but an env secret is set, use the env secret directly + if (!configPath) { + if (envSecret && envSecret.length > 0) { + return envSecret; + } + + throw new Error( + "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." + ); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + let secret = config?.server?.secret; + if (!secret || secret.length === 0) { + // Fall back to SERVER_SECRET env var if config does not contain server.secret + if (envSecret && envSecret.length > 0) { + secret = envSecret; + } + } + + if (!secret || secret.length === 0) { + throw new Error( + "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." + ); + } + + return secret; +} + export default async function migration() { console.log(`Running setup script ${version}...`); const location = path.join(APP_PATH, "db", "db.sqlite"); const db = new Database(location); - // set all admin role sudo to "full"; all other roles to "none" - // all roles set hoemdir to true - - // generate ca certs for all orgs? - // set authDaemonMode to "site" for all site-resources - try { - db.transaction(() => {})(); + const secret = getServerSecret(); + + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + // Create roundTripMessageTracker table for tracking message round-trips + db.prepare( + ` + CREATE TABLE 'roundTripMessageTracker' ( + 'messageId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'clientId' text, + 'messageType' text, + 'sentAt' integer NOT NULL, + 'receivedAt' integer, + 'error' text, + 'complete' integer DEFAULT 0 NOT NULL + ); + ` + ).run(); + + // Org SSH CA and billing columns + db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPrivateKey' text;`).run(); + db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPublicKey' text;`).run(); + db.prepare(`ALTER TABLE 'orgs' ADD 'isBillingOrg' integer;`).run(); + db.prepare(`ALTER TABLE 'orgs' ADD 'billingOrgId' text;`).run(); + + // Role SSH sudo and unix group columns + db.prepare( + `ALTER TABLE 'roles' ADD 'sshSudoMode' text DEFAULT 'none';` + ).run(); + db.prepare( + `ALTER TABLE 'roles' ADD 'sshSudoCommands' text DEFAULT '[]';` + ).run(); + db.prepare( + `ALTER TABLE 'roles' ADD 'sshCreateHomeDir' integer DEFAULT 1;` + ).run(); + db.prepare( + `ALTER TABLE 'roles' ADD 'sshUnixGroups' text DEFAULT '[]';` + ).run(); + + // Site resource auth daemon columns + db.prepare( + `ALTER TABLE 'siteResources' ADD 'authDaemonPort' integer DEFAULT 22123;` + ).run(); + db.prepare( + `ALTER TABLE 'siteResources' ADD 'authDaemonMode' text DEFAULT 'site';` + ).run(); + + // UserOrg PAM username for SSH + db.prepare(`ALTER TABLE 'userOrgs' ADD 'pamUsername' text;`).run(); + + // Set all admin role sudo to "full"; other roles keep default "none" + db.prepare( + `UPDATE 'roles' SET 'sshSudoMode' = 'full' WHERE isAdmin = 1;` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + const orgRows = db.prepare("SELECT orgId FROM orgs").all() as { + orgId: string; + }[]; + + // Generate and store encrypted SSH CA keys for all orgs + const updateOrgCaKeys = db.prepare( + "UPDATE orgs SET sshCaPrivateKey = ?, sshCaPublicKey = ? WHERE orgId = ?" + ); + + const failedOrgIds: string[] = []; + + for (const row of orgRows) { + try { + const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + updateOrgCaKeys.run( + encryptedPrivateKey, + ca.publicKeyOpenSSH, + row.orgId + ); + } catch (err) { + failedOrgIds.push(row.orgId); + console.error( + `Error: No CA was generated for organization "${row.orgId}".`, + err instanceof Error ? err.message : err + ); + } + } + + if (orgRows.length > 0) { + const succeeded = orgRows.length - failedOrgIds.length; + console.log( + `Generated and stored SSH CA keys for ${succeeded} org(s).` + ); + } + + if (failedOrgIds.length > 0) { + console.error( + `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join(", ")}` + ); + } console.log(`Migrated database`); } catch (e) { From 388f710379b81d2de9875c1e90e8e66cc3fd1be1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 25 Feb 2026 11:37:31 -0800 Subject: [PATCH 12/33] add pg migration --- server/setup/migrationsPg.ts | 6 +- server/setup/scriptsPg/1.16.0.ts | 179 +++++++++++++++++++++++++++ server/setup/scriptsSqlite/1.16.0.ts | 7 +- 3 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 server/setup/scriptsPg/1.16.0.ts diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 8d27435aa..1ace73474 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -5,6 +5,7 @@ import semver from "semver"; import { versionMigrations } from "../db/pg"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; +import { build } from "@server/build"; import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; @@ -19,7 +20,7 @@ import m11 from "./scriptsPg/1.14.0"; import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; -import { build } from "@server/build"; +import m15 from "./scriptsPg/1.16.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -39,7 +40,8 @@ const migrations = [ { version: "1.14.0", run: m11 }, { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, - { version: "1.15.4", run: m14 } + { version: "1.15.4", run: m14 }, + { version: "1.16.0", run: m15 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/scriptsPg/1.16.0.ts b/server/setup/scriptsPg/1.16.0.ts new file mode 100644 index 000000000..f87bd7f24 --- /dev/null +++ b/server/setup/scriptsPg/1.16.0.ts @@ -0,0 +1,179 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { encrypt } from "@server/lib/crypto"; +import { generateCA } from "@server/private/lib/sshCA"; +import fs from "fs"; +import yaml from "js-yaml"; + +const version = "1.16.0"; + +function getServerSecret(): string { + const envSecret = process.env.SERVER_SECRET; + + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + // If no config file but an env secret is set, use the env secret directly + if (!configPath) { + if (envSecret && envSecret.length > 0) { + return envSecret; + } + + throw new Error( + "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." + ); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + let secret = config?.server?.secret; + if (!secret || secret.length === 0) { + // Fall back to SERVER_SECRET env var if config does not contain server.secret + if (envSecret && envSecret.length > 0) { + secret = envSecret; + } + } + + if (!secret || secret.length === 0) { + throw new Error( + "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." + ); + } + + return secret; +} + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Ensure server secret exists before running migration (required for org CA key generation) + getServerSecret(); + + try { + await db.execute(sql`BEGIN`); + + // Schema changes + await db.execute(sql` + CREATE TABLE "roundTripMessageTracker" ( + "messageId" serial PRIMARY KEY NOT NULL, + "clientId" varchar, + "messageType" varchar, + "sentAt" bigint NOT NULL, + "receivedAt" bigint, + "error" text, + "complete" boolean DEFAULT false NOT NULL + ); + `); + + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPrivateKey" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPublicKey" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "isBillingOrg" boolean;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "billingOrgId" varchar;` + ); + + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshSudoMode" varchar(32) DEFAULT 'none';` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshSudoCommands" text DEFAULT '[]';` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshCreateHomeDir" boolean DEFAULT true;` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshUnixGroups" text DEFAULT '[]';` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonPort" integer DEFAULT 22123;` + ); + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonMode" varchar(32) DEFAULT 'site';` + ); + + await db.execute( + sql`ALTER TABLE "userOrgs" ADD COLUMN "pamUsername" varchar;` + ); + + // Set all admin role sudo to "full"; other roles keep default "none" + await db.execute( + sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Generate and store encrypted SSH CA keys for all orgs + try { + const secret = getServerSecret(); + + const orgQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`); + const orgRows = orgQuery.rows as { orgId: string }[]; + + const failedOrgIds: string[] = []; + + for (const row of orgRows) { + try { + const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + + await db.execute(sql` + UPDATE "orgs" + SET "sshCaPrivateKey" = ${encryptedPrivateKey}, + "sshCaPublicKey" = ${ca.publicKeyOpenSSH} + WHERE "orgId" = ${row.orgId}; + `); + } catch (err) { + failedOrgIds.push(row.orgId); + console.error( + `Error: No CA was generated for organization "${row.orgId}".`, + err instanceof Error ? err.message : err + ); + } + } + + if (orgRows.length > 0) { + const succeeded = orgRows.length - failedOrgIds.length; + console.log( + `Generated and stored SSH CA keys for ${succeeded} org(s).` + ); + } + + if (failedOrgIds.length > 0) { + console.error( + `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join( + ", " + )}` + ); + } + } catch (e) { + console.error( + "Error while generating SSH CA keys for orgs after migration:", + e + ); + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts index cf128e481..0abc9e0c1 100644 --- a/server/setup/scriptsSqlite/1.16.0.ts +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -55,12 +55,13 @@ function getServerSecret(): string { export default async function migration() { console.log(`Running setup script ${version}...`); + // Ensure server secret exists before running migration (required for org CA key generation) + getServerSecret(); + const location = path.join(APP_PATH, "db", "db.sqlite"); const db = new Database(location); try { - const secret = getServerSecret(); - db.pragma("foreign_keys = OFF"); db.transaction(() => { @@ -123,6 +124,8 @@ export default async function migration() { }[]; // Generate and store encrypted SSH CA keys for all orgs + const secret = getServerSecret(); + const updateOrgCaKeys = db.prepare( "UPDATE orgs SET sshCaPrivateKey = ?, sshCaPublicKey = ? WHERE orgId = ?" ); From 8fca243c9a441c6614eae104852bab61f599eaff Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 25 Feb 2026 11:59:16 -0800 Subject: [PATCH 13/33] update tierMatrix --- server/lib/billing/tierMatrix.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 20f8001de..c08bcea71 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -48,5 +48,5 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] }; From b8d468f6de1b4c3909b9512d300b5edeeb9482ed Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 14:22:24 -0800 Subject: [PATCH 14/33] Bump version --- server/lib/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 4f7e4d62c..d53bd70bb 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.15.4"; +export const APP_VERSION = "1.16.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); From 14cab3fdb867b378bdf5a9df1b4c145dd68a1290 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 14:37:52 -0800 Subject: [PATCH 15/33] Update phrase --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2dfa496f9..961930bdc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2543,7 +2543,7 @@ "internalResourceAuthDaemonSite": "On Site", "internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).", "internalResourceAuthDaemonRemote": "Remote Host", - "internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on a host that is not the site.", + "internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on this resource's destination - not the site.", "internalResourceAuthDaemonPort": "Daemon Port (optional)", "orgAuthWhatsThis": "Where can I find my organization ID?", "learnMore": "Learn more", From 959f68b520009de2804c901af86913bd582e8c28 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 14:43:47 -0800 Subject: [PATCH 16/33] Restrict cidr resource --- server/private/routers/ssh/signSshKey.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index fbdee72d1..13a7fe668 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -310,6 +310,15 @@ export async function signSshKey( ); } + if (resource.mode == "cidr") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "SSHing is not supported for CIDR resources" + ) + ); + } + // Check if the user has access to the resource const hasAccess = await canUserAccessSiteResource({ userId: userId, From b017877826030ae9276db3a545fd0641c8d43265 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 25 Feb 2026 14:49:17 -0800 Subject: [PATCH 17/33] hide ssh access tab for cidr resources --- src/components/InternalResourceForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 08eb5a24d..3d18bf276 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -640,7 +640,7 @@ export function InternalResourceForm({ title: t("editInternalResourceDialogAccessPolicy"), href: "#" }, - ...(disableEnterpriseFeatures + ...(disableEnterpriseFeatures || mode === "cidr" ? [] : [{ title: t("sshAccess"), href: "#" }]) ]} @@ -1188,7 +1188,7 @@ export function InternalResourceForm({
{/* SSH Access tab */} - {!disableEnterpriseFeatures && ( + {!disableEnterpriseFeatures && mode !== "cidr" && (
From 5cf13a963d62bfc040119acb4d52447bfa07ff06 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 15:29:37 -0800 Subject: [PATCH 18/33] Add missing saving username --- server/private/routers/ssh/signSshKey.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 13a7fe668..aa556228c 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -185,6 +185,17 @@ export async function signSshKey( ) ); } + + // save it to the database for future use so we dont have to keep doing this + await db + .update(userOrgs) + .set({ pamUsername: usernameToUse }) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, userId) + ) + ); } else { return next( createHttpError( From c3847e6001fb286732d94aae7d21534b1a6d9747 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 25 Feb 2026 15:36:22 -0800 Subject: [PATCH 19/33] Prefix usernames --- server/private/routers/ssh/signSshKey.ts | 26 +++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index aa556228c..501b29bd5 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -176,7 +176,7 @@ export async function signSshKey( } else if (req.user?.username) { usernameToUse = req.user.username; // We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates - usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, ""); + usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-"); if (!usernameToUse) { return next( createHttpError( @@ -185,17 +185,6 @@ export async function signSshKey( ) ); } - - // save it to the database for future use so we dont have to keep doing this - await db - .update(userOrgs) - .set({ pamUsername: usernameToUse }) - .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.userId, userId) - ) - ); } else { return next( createHttpError( @@ -205,6 +194,9 @@ export async function signSshKey( ); } + // prefix with p- + usernameToUse = `p-${usernameToUse}`; + // check if we have a existing user in this org with the same const [existingUserWithSameName] = await db .select() @@ -250,6 +242,16 @@ export async function signSshKey( ); } } + + await db + .update(userOrgs) + .set({ pamUsername: usernameToUse }) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, userId) + ) + ); } else { usernameToUse = userOrg.pamUsername; } From 1c0949e9577c6fc3d78b7ba4b6df5588382876df Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:47:58 -0800 Subject: [PATCH 20/33] New translations en-us.json (French) --- messages/fr-FR.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 004354f16..f053ac3f0 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Échec de la bascule de la ressource", "resourcesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource", "access": "Accès", + "accessControl": "Contrôle d'accès", "shareLink": "Lien de partage {resource}", "resourceSelect": "Sélectionner une ressource", "shareLinks": "Liens de partage", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Oups! La page que vous recherchez n'existe pas.", "overview": "Vue d'ensemble", "home": "Accueil", - "accessControl": "Contrôle d'accès", "settings": "Paramètres", "usersAll": "Tous les utilisateurs", "license": "Licence", From 6322fd9eefe705b6ebb74780c6cc2e9fef7cf2fe Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:47:59 -0800 Subject: [PATCH 21/33] New translations en-us.json (Spanish) --- messages/es-ES.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index 6bb73cdae..619c7596c 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Error al cambiar el recurso", "resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso", "access": "Acceder", + "accessControl": "Control de acceso", "shareLink": "{resource} Compartir Enlace", "resourceSelect": "Seleccionar recurso", "shareLinks": "Compartir enlaces", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.", "overview": "Resumen", "home": "Inicio", - "accessControl": "Control de acceso", "settings": "Ajustes", "usersAll": "Todos los usuarios", "license": "Licencia", From 98154b5de3a7f503df9a9bee6492c9ec520bbb1d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:01 -0800 Subject: [PATCH 22/33] New translations en-us.json (Bulgarian) --- messages/bg-BG.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 60e4b401e..0332e4f33 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Неуспешно превключване на ресурса", "resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса", "access": "Достъп", + "accessControl": "Контрол на достъпа", "shareLink": "{resource} Сподели връзка", "resourceSelect": "Изберете ресурс", "shareLinks": "Споделени връзки", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.", "overview": "Общ преглед", "home": "Начало", - "accessControl": "Контрол на достъпа", "settings": "Настройки", "usersAll": "Всички потребители", "license": "Лиценз", From f138609f48184042df4294802be35cd2a3eae6d4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:02 -0800 Subject: [PATCH 23/33] New translations en-us.json (Czech) --- messages/cs-CZ.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index b7666db1b..0c1c3a98e 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Nepodařilo se přepnout zdroj", "resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje", "access": "Přístup", + "accessControl": "Kontrola přístupu", "shareLink": "{resource} Sdílet odkaz", "resourceSelect": "Vyberte zdroj", "shareLinks": "Sdílet odkazy", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Jejda! Stránka, kterou hledáte, neexistuje.", "overview": "Přehled", "home": "Domů", - "accessControl": "Kontrola přístupu", "settings": "Nastavení", "usersAll": "Všichni uživatelé", "license": "Licence", From 6e4193dae355c7a4b2b7840fcbaaa85633aa24fd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:04 -0800 Subject: [PATCH 24/33] New translations en-us.json (German) --- messages/de-DE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 15663fa47..678e6881d 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Fehler beim Umschalten der Ressource", "resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten", "access": "Zugriff", + "accessControl": "Zugriffskontrolle", "shareLink": "{resource} Freigabe-Link", "resourceSelect": "Ressource auswählen", "shareLinks": "Freigabe-Links", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Hoppla! Die gesuchte Seite existiert nicht.", "overview": "Übersicht", "home": "Startseite", - "accessControl": "Zugriffskontrolle", "settings": "Einstellungen", "usersAll": "Alle Benutzer", "license": "Lizenz", From 7779ed24fe1bd95bd7e8c3bb7f00ba2d46d12763 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:05 -0800 Subject: [PATCH 25/33] New translations en-us.json (Italian) --- messages/it-IT.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 5a60a2963..69185abc7 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Impossibile attivare/disattivare la risorsa", "resourcesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa", "access": "Accesso", + "accessControl": "Controllo Accessi", "shareLink": "Link di Condivisione {resource}", "resourceSelect": "Seleziona risorsa", "shareLinks": "Link di Condivisione", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Oops! La pagina che stai cercando non esiste.", "overview": "Panoramica", "home": "Home", - "accessControl": "Controllo Accessi", "settings": "Impostazioni", "usersAll": "Tutti Gli Utenti", "license": "Licenza", From 53fc7ab6e34a75782e9d1b083e2a0d5c7c7db7ee Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:07 -0800 Subject: [PATCH 26/33] New translations en-us.json (Korean) --- messages/ko-KR.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 038658108..bc2b26dad 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.", "resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "access": "접속", + "accessControl": "액세스 제어", "shareLink": "{resource} 공유 링크", "resourceSelect": "리소스 선택", "shareLinks": "공유 링크", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.", "overview": "개요", "home": "홈", - "accessControl": "액세스 제어", "settings": "설정", "usersAll": "모든 사용자", "license": "라이선스", From 5e1f6085e391470bc0d57c545dc655431f343ca6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:08 -0800 Subject: [PATCH 27/33] New translations en-us.json (Dutch) --- messages/nl-NL.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index caa2ed17a..8ecfcc212 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Bron wisselen mislukt", "resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", "access": "Toegangsrechten", + "accessControl": "Toegangs controle", "shareLink": "{resource} Share link", "resourceSelect": "Selecteer resource", "shareLinks": "Links delen", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Oeps! De pagina die je zoekt bestaat niet.", "overview": "Overzicht.", "home": "Startpagina", - "accessControl": "Toegangs controle", "settings": "Instellingen", "usersAll": "Alle gebruikers", "license": "Licentie", From 3df71fd2bc04060461383f41bc6c71a41271e41f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:09 -0800 Subject: [PATCH 28/33] New translations en-us.json (Polish) --- messages/pl-PL.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 6203f4cc2..9b8889f39 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Nie udało się przełączyć zasobu", "resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu", "access": "Dostęp", + "accessControl": "Kontrola dostępu", "shareLink": "Link udostępniania {resource}", "resourceSelect": "Wybierz zasób", "shareLinks": "Linki udostępniania", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Ups! Strona, której szukasz, nie istnieje.", "overview": "Przegląd", "home": "Strona główna", - "accessControl": "Kontrola dostępu", "settings": "Ustawienia", "usersAll": "Wszyscy użytkownicy", "license": "Licencja", From 411a34e15e6cc921b2c34cac09461d25be3a38af Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:11 -0800 Subject: [PATCH 29/33] New translations en-us.json (Portuguese) --- messages/pt-PT.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index b623b2b25..89f5f41ed 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Falha ao alternar recurso", "resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso", "access": "Acesso", + "accessControl": "Controle de Acesso", "shareLink": "Link de Compartilhamento {resource}", "resourceSelect": "Selecionar recurso", "shareLinks": "Links de Compartilhamento", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Ops! A página que você está procurando não existe.", "overview": "Visão Geral", "home": "Início", - "accessControl": "Controle de Acesso", "settings": "Configurações", "usersAll": "Todos os Utilizadores", "license": "Licença", From ec8a9fe3d29ab141b5cd4f016a9a7148bcaa3ce7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:12 -0800 Subject: [PATCH 30/33] New translations en-us.json (Russian) --- messages/ru-RU.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index f4dd0ac39..5d6fdc0c3 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Не удалось переключить ресурс", "resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса", "access": "Доступ", + "accessControl": "Контроль доступа", "shareLink": "Общая ссылка {resource}", "resourceSelect": "Выберите ресурс", "shareLinks": "Общие ссылки", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.", "overview": "Обзор", "home": "Главная", - "accessControl": "Контроль доступа", "settings": "Настройки", "usersAll": "Все пользователи", "license": "Лицензия", From 5fb35d12d7925283f2ba5c3946b6e058aa026d65 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:13 -0800 Subject: [PATCH 31/33] New translations en-us.json (Turkish) --- messages/tr-TR.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index f853629d0..b17813464 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Kaynak değiştirilemedi", "resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu", "access": "Erişim", + "accessControl": "Erişim Kontrolü", "shareLink": "{resource} Paylaşım Bağlantısı", "resourceSelect": "Kaynak seçin", "shareLinks": "Paylaşım Bağlantıları", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Oops! Aradığınız sayfa mevcut değil.", "overview": "Genel Bakış", "home": "Ana Sayfa", - "accessControl": "Erişim Kontrolü", "settings": "Ayarlar", "usersAll": "Tüm Kullanıcılar", "license": "Lisans", From 485f4f1c8e2a8a2f1c6e6d222b7891ea034d1df7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:15 -0800 Subject: [PATCH 32/33] New translations en-us.json (Chinese Simplified) --- messages/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 29fe80390..e2ee77b6f 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "切换资源失败", "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", + "accessControl": "访问控制", "shareLink": "{resource} 的分享链接", "resourceSelect": "选择资源", "shareLinks": "分享链接", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "哎呀!您正在查找的页面不存在。", "overview": "概览", "home": "首页", - "accessControl": "访问控制", "settings": "设置", "usersAll": "所有用户", "license": "许可协议", From ea49e179f918aa08c4726deeba34468f8eaefaec Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 25 Feb 2026 15:48:16 -0800 Subject: [PATCH 33/33] New translations en-us.json (Norwegian Bokmal) --- messages/nb-NO.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 503f42659..9e1ad1b04 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -650,6 +650,7 @@ "resourcesErrorUpdate": "Feilet å slå av/på ressurs", "resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen", "access": "Tilgang", + "accessControl": "Tilgangskontroll", "shareLink": "{resource} Del Lenke", "resourceSelect": "Velg ressurs", "shareLinks": "Del lenker", @@ -1038,7 +1039,6 @@ "pageNotFoundDescription": "Oops! Siden du leter etter finnes ikke.", "overview": "Oversikt", "home": "Hjem", - "accessControl": "Tilgangskontroll", "settings": "Innstillinger", "usersAll": "Alle brukere", "license": "Lisens",